    // [unit->REQ-CONSENT-1] reserved capabilities refuse unconditionally —
    // even with a grant row present (defense in depth) and even with a live
    // escalation target available: neither grants nor escalation are
    // consulted for a deferred capability.
    #[test]
    fn reserved_capabilities_refuse_even_when_granted() {
        let mut store = GrantStore::default();
        store.grant(CAP_REMOTE_EXEC, "ling", "aa11", None);
        store.grant(CAP_INSTANTIATE_ANYWHERE, "ling", "aa11", None);

        for cap in RESERVED_CAPABILITIES {
            assert_eq!(
                decide(&store, cap, "ling", "aa11", None, Some(target("doyle"))),
                GrantDecision::RefusedReserved,
                "{cap} must refuse unconditionally"
            );
        }
    }
}

    // [unit->REQ-CONSENT-1] reserved capabilities refuse unconditionally —
    // even with a grant row present (defense in depth) and even with a live
    // escalation target available: neither grants nor escalation are
    // consulted for a deferred capability.
    #[test]
    fn reserved_capabilities_refuse_even_when_granted() {
        let mut store = GrantStore::default();
        store.grant(CAP_REMOTE_EXEC, "ling", "aa11", None);
        store.grant(CAP_INSTANTIATE_ANYWHERE, "ling", "aa11", None);

        for cap in RESERVED_CAPABILITIES {
            assert_eq!(
                decide(&store, cap, "ling", "aa11", None, Some(target("doyle"))),
                GrantDecision::RefusedReserved,
                "{cap} must refuse unconditionally"
            );
        }
    }

    fn ask(qualifier: Option<&str>) -> EscalationAsk {
        EscalationAsk {
            capability: CAP_SPAWN_SHELL.into(),
            agent: "ling".into(),
            node: "aa11".into(),
            qualifier: qualifier.map(str::to_string),
        }
    }

    // [unit->REQ-CONSENT-2] the ask survives the notif body losslessly, with
    // and without a qualifier; non-escalation consent bodies (the update
    // producer's) parse to None, never a panic.
    #[test]
    fn escalation_ask_body_roundtrip() {
        for a in [ask(Some("OsNotify")), ask(None)] {
            let parsed = EscalationAsk::parse_body(&a.body());
            assert_eq!(parsed.as_ref(), Some(&a));
        }
        assert_eq!(EscalationAsk::parse_body("update-available 1.2.3\nconfirm"), None);
        assert_eq!(EscalationAsk::parse_body(""), None);
        assert_eq!(EscalationAsk::parse_body("consent-needed mangled"), None);
    }

    // [unit->REQ-CONSENT-2] answer application: allow-once proceeds without
    // persisting; allow-always writes exactly the asked tuple (the next
    // decide() passes silently); deny persists nothing and does not proceed.
    #[test]
    fn escalation_answers_apply() {
        let a = ask(Some("OsNotify"));

        let mut s = GrantStore::default();
        assert_eq!(
            apply_escalation_answer(&mut s, &a, EscalationAnswer::AllowOnce),
            EscalationOutcome::ProceedOnce
        );
        assert!(s.rows.is_empty(), "once never persists");

        assert_eq!(
            apply_escalation_answer(&mut s, &a, EscalationAnswer::Deny),
            EscalationOutcome::Denied
        );
        assert!(s.rows.is_empty(), "deny never persists");

        assert_eq!(
            apply_escalation_answer(&mut s, &a, EscalationAnswer::AllowAlways),
            EscalationOutcome::ProceedGranted
        );
        assert_eq!(
            decide(&s, CAP_SPAWN_SHELL, "ling", "aa11", Some("OsNotify"), None),
            GrantDecision::Allowed,
            "the written grant satisfies the gate"
        );
        // The grant is the exact tuple — a different qualifier still escalates.
        assert_eq!(
            decide(&s, CAP_SPAWN_SHELL, "ling", "aa11", Some("GameRobot"), None),
            GrantDecision::NeedsEscalation(None)
        );
    }

    // [unit->REQ-CONSENT-2] the escalation producer rides the consent notif
    // kind and first-fires at the most-recently-active live endpoint; the
    // fired body parses back to the original ask.
    #[test]
    fn escalation_notif_fires_to_most_recent_and_roundtrips() {
        use crate::notif::{FirstFireOutcome, NotifSurfacePolicy, NOTIF_KIND_CONSENT};
        use crate::test_home::with_home;
        use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
        use spt_store::liveness::STATUS_ONLINE;
        use spt_store::perch::{self, ParentHint};
        use spt_store::visibility::VisibilityStore;

        with_home(|home| {
            let owlery = perch::owlery_dir();
            for (id, ms) in [("doyle", 100u64), ("ling", 500)] {
                let p = perch::resolve_perch_path_in(&owlery, id, ParentHint::Infer);
                std::fs::create_dir_all(&p).unwrap();
                write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
                set_status(&p, STATUS_ONLINE).unwrap();
                set_last_active(&p, ms).unwrap();
            }

            let store = spt_store::notif::NotifStore::open_at(&home.join("notifs.db")).unwrap();
            let mut epochs = spt_store::epoch::EpochSource::load_from(&home.join("epoch"));
            let policy = NotifSurfacePolicy {
                subnets: vec![("home".into(), false)],
                vis: VisibilityStore::default(),
            };

            let a = ask(Some("OsNotify"));
            let (row, fired) = produce_escalation_notif(
                &a, &store, "cafe", &mut epochs, "home", &policy, &owlery, 50_000,
            )
            .unwrap();

            assert!(
                matches!(fired, FirstFireOutcome::Fired { ref to, .. } if to == "ling"),
                "fires at the most-recently-active live endpoint, got {fired:?}"
            );
            assert_eq!(row.kind, NOTIF_KIND_CONSENT);
            assert_eq!(row.from_id, "ling", "the asking agent is the from");
            assert_eq!(EscalationAsk::parse_body(&row.body).as_ref(), Some(&a));
        });
    }
}

+    fn ask(qualifier: Option<&str>) -> EscalationAsk {

+            capability: CAP_SPAWN_SHELL.into(),

+            qualifier: qualifier.map(str::to_string),

+    // [unit->REQ-CONSENT-2] the ask survives the notif body losslessly, with

+    // and without a qualifier; non-escalation consent bodies (the update

+    // producer's) parse to None, never a panic.

+    fn escalation_ask_body_roundtrip() {

+        for a in [ask(Some("OsNotify")), ask(None)] {

+            let parsed = EscalationAsk::parse_body(&a.body());

+            assert_eq!(parsed.as_ref(), Some(&a));

+        assert_eq!(EscalationAsk::parse_body("update-available 1.2.3\nconfirm"), None);

+        assert_eq!(EscalationAsk::parse_body(""), None);

+        assert_eq!(EscalationAsk::parse_body("consent-needed mangled"), None);

+    // [unit->REQ-CONSENT-2] answer application: allow-once proceeds without

+    // persisting; allow-always writes exactly the asked tuple (the next

+    // decide() passes silently); deny persists nothing and does not proceed.

+        let mut s = GrantStore::default();

+            apply_escalation_answer(&mut s, &a, EscalationAnswer::AllowOnce),

+            EscalationOutcome::ProceedOnce

+        assert!(s.rows.is_empty(), "once never persists");

+            apply_escalation_answer(&mut s, &a, EscalationAnswer::Deny),

+        assert!(s.rows.is_empty(), "deny never persists");

+            apply_escalation_answer(&mut s, &a, EscalationAnswer::AllowAlways),

+            EscalationOutcome::ProceedGranted

+            decide(&s, CAP_SPAWN_SHELL, "ling", "aa11", Some("OsNotify"), None),

+            "the written grant satisfies the gate"

+        // The grant is the exact tuple — a different qualifier still escalates.

+            decide(&s, CAP_SPAWN_SHELL, "ling", "aa11", Some("GameRobot"), None),

+            GrantDecision::NeedsEscalation(None)

+    // [unit->REQ-CONSENT-2] the escalation producer rides the consent notif

+    // kind and first-fires at the most-recently-active live endpoint; the

+    // fired body parses back to the original ask.

+    fn escalation_notif_fires_to_most_recent_and_roundtrips() {

+        use crate::notif::{FirstFireOutcome, NotifSurfacePolicy, NOTIF_KIND_CONSENT};

+        use crate::test_home::with_home;

+        use spt_store::info::{set_last_active, set_status, write_info, InfoJson};

+        use spt_store::liveness::STATUS_ONLINE;

+        use spt_store::perch::{self, ParentHint};

+        use spt_store::visibility::VisibilityStore;

+            let owlery = perch::owlery_dir();

+                let p = perch::resolve_perch_path_in(&owlery, id, ParentHint::Infer);

+                std::fs::create_dir_all(&p).unwrap();

+                write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();

+                set_status(&p, STATUS_ONLINE).unwrap();

+                set_last_active(&p, ms).unwrap();

+            let store = spt_store::notif::NotifStore::open_at(&home.join("notifs.db")).unwrap();

+            let mut epochs = spt_store::epoch::EpochSource::load_from(&home.join("epoch"));

+            let policy = NotifSurfacePolicy {

+                subnets: vec![("home".into(), false)],

+                vis: VisibilityStore::default(),

+            let a = ask(Some("OsNotify"));

+            let (row, fired) = produce_escalation_notif(

+                &a, &store, "cafe", &mut epochs, "home", &policy, &owlery, 50_000,

+                matches!(fired, FirstFireOutcome::Fired { ref to, .. } if to == "ling"),

+                "fires at the most-recently-active live endpoint, got {fired:?}"

+            assert_eq!(row.kind, NOTIF_KIND_CONSENT);

+            assert_eq!(row.from_id, "ling", "the asking agent is the from");

+            assert_eq!(EscalationAsk::parse_body(&row.body).as_ref(), Some(&a));


Fix test ambiguity — asker and MRA target must differ:


Edit

            let owlery = perch::owlery_dir();
            for (id, ms) in [("doyle", 100u64), ("ling", 500)] {

            let owlery = perch::owlery_dir();
            // "doyle" is most-recently-active; the asker ("ling") is not —
            // the prompt must land at the user's session, not the asker's.
            for (id, ms) in [("doyle", 500u64), ("ling", 100)] {


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\grants.rs has been updated successfully. (file state is current in your context — no need to Read it back)

            let owlery = perch::owlery_dir();
            for (id, ms) in [("doyle", 100u64), ("ling", 500)] {

            let owlery = perch::owlery_dir();
            // "doyle" is most-recently-active; the asker ("ling") is not —
            // the prompt must land at the user's session, not the asker's.
            for (id, ms) in [("doyle", 500u64), ("ling", 100)] {

+            // "doyle" is most-recently-active; the asker ("ling") is not —

+            // the prompt must land at the user's session, not the asker's.

let p = perch::resolve_perch_path_in(&owlery, id, ParentHint::Infer);

write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();


            assert!(
                matches!(fired, FirstFireOutcome::Fired { ref to, .. } if to == "ling"),
                "fires at the most-recently-active live endpoint, got {fired:?}"
            );
            assert_eq!(row.kind, NOTIF_KIND_CONSENT);
            assert_eq!(row.from_id, "ling", "the asking agent is the from");

            assert!(
                matches!(fired, FirstFireOutcome::Fired { ref to, .. } if to == "doyle"),
                "fires at the most-recently-active live endpoint, got {fired:?}"
            );
            assert_eq!(row.kind, NOTIF_KIND_CONSENT);
            assert_eq!(row.from_id, "ling", "the asking agent is the from");


            assert!(
                matches!(fired, FirstFireOutcome::Fired { ref to, .. } if to == "ling"),
                "fires at the most-recently-active live endpoint, got {fired:?}"
            );
            assert_eq!(row.kind, NOTIF_KIND_CONSENT);
            assert_eq!(row.from_id, "ling", "the asking agent is the from");

            assert!(
                matches!(fired, FirstFireOutcome::Fired { ref to, .. } if to == "doyle"),
                "fires at the most-recently-active live endpoint, got {fired:?}"
            );
            assert_eq!(row.kind, NOTIF_KIND_CONSENT);
            assert_eq!(row.from_id, "ling", "the asking agent is the from");

-                matches!(fired, FirstFireOutcome::Fired { ref to, .. } if to == "ling"),

+                matches!(fired, FirstFireOutcome::Fired { ref to, .. } if to == "doyle"),


PowerShell

cargo test -p spt-daemon --lib grants 2>&1 | Select-Object -Last 12

Run grants module tests including D1b


     Running unittests src\lib.rs (target\debug\deps\spt_daemon-df85ccfb281dc93f.exe)

running 6 tests
test grants::tests::no_session_escalates_with_none_target ... ok
test grants::tests::escalation_answers_apply ... ok
test grants::tests::reserved_capabilities_refuse_even_when_granted ... ok
test grants::tests::ungranted_escalates_granted_allows ... ok
test grants::tests::escalation_ask_body_roundtrip ... ok
test grants::tests::escalation_notif_fires_to_most_recent_and_roundtrips ... ok

test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 133 filtered out; finished in 0.10s


D1b green. Activate REQ-CONSENT-2 + commit:


Edit

required_stages = []  # rule 5: activate at M5-D1b/D1c (escalation + flag authoring land)

required_stages = ["impl", "unit"]  # activated M5-D1b: spt-daemon::grants escalation — EscalationAsk lossless body roundtrip over the consent-kind notif (produce_escalation_notif rides produce_and_first_fire to the most-recently-active session) + apply_escalation_answer (allow-once proceeds unpersisted / allow-always writes the exact tuple / deny persists nothing). D1c extends with the pre-consent flag authoring paths. int = the D3d spawn-gate consumer E2E


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

title = "Interactive consent escalation: an ungated high-risk action routes a consent prompt to the user's most-recently-active session; allow-once / allow-always (writes a grant) / deny; pre-consent flags (can_shutdown, shell_wake_spawn_anywhere) author grants via manifest/settings (CONTEXT Consent & security gates)"

-required_stages = []  # rule 5: activate at M5-D1b/D1c (escalation + flag authoring land)

+required_stages = ["impl", "unit"]  # activated M5-D1b: spt-daemon::grants escalation — EscalationAsk lossless body roundtrip over the consent-kind notif (produce_escalation_notif rides produce_and_first_fire to the most-recently-active session) + apply_escalation_answer (allow-once proceeds unpersisted / allow-always writes the exact tuple / deny persists nothing). D1c extends with the pre-consent flag authoring paths. int = the D3d spawn-gate consumer E2E

title = "Presence resolution: the presence datum (last_active_node, last_active_endpoint, ts) gossiped subnet-wide via the agent-interaction heartbeat (rides registry distribution, visibility-gated) + one first-class most-recently-active resolution API consumed by notif first-fire, update-consent delivery, consent escalation, and shell wake resolution (M5 scope decision 1: resolution only — the PresenceChannel endpoint stays deferred)"


PowerShell

cargo clippy -p spt-daemon --all-targets -- -D warnings 2>&1 | Select-Object -Last 3; traceable-reqs check 2>&1 | Select-Object -First 1

Clippy + traceability before D1b commit


    Checking spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Checking spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.35s
134 requirements: 134 complete, 0 incomplete, 0 findings


git add crates/spt-daemon/src/grants.rs traceable-reqs.toml && git commit -m @'
feat(daemon): M5-D1b interactive consent escalation over the notif primitive

An ungranted ask (GrantDecision::NeedsEscalation) now has its full path:
EscalationAsk encodes losslessly into a consent-kind notif body
(machine-parseable first line + human hint; parse_body tolerates foreign
consent bodies as None); produce_escalation_notif rides the ADR-0007
producer front door, firing at the user most-recently-active session with
the asking agent as from; apply_escalation_answer terminates the prompt —
allow-once proceeds without persisting, allow-always writes the exact
asked tuple (next decide() passes silently), deny persists nothing.

The prompt UX itself stays harness-native (the consent.rs stance): the
producer + answer-application are the typed seams it plugs into.

REQ-CONSENT-2 activated impl+unit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@ && git push

Commit and push M5-D1b slice


warning: in the working copy of 'crates/spt-daemon/src/grants.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
[dev-freeform eac1524] feat(daemon): M5-D1b interactive consent escalation over the notif primitive
 2 files changed, 256 insertions(+), 1 deletion(-)
To https://github.com/SaberMage/spt-core.git
   a602b1a..eac1524  dev-freeform -> dev-freeform


D1c now — pre-consent flag authoring helpers:


Edit

/// The user's answer to an escalation prompt.

// ─── Pre-consent flags (M5-D1c, REQ-CONSENT-2) ──────────────────────────────
//
// CONTEXT §Consent: the shell-manifest `can_shutdown` and endpoint-settings
// `shell_wake_spawn_anywhere` flags "are simply grants authored ahead of
// time via manifest/endpoint settings (same model, different authoring
// path)". These helpers ARE that authoring path; their call sites are the
// shell-spawn (D3) and wake-resolution (D4c) machinery reading the flags.

/// The qualifier narrowing a flag-authored `instantiate-anywhere` row to the
/// shell-wake path: `shell_wake_spawn_anywhere` pre-consents shells to
/// fresh-spawn **their owner, to wake it** — not general remote
/// instantiation (CONTEXT §Shell sleep/wake).
pub const QUAL_SHELL_WAKE: &str = "shell-wake";

/// Author the `can_shutdown` pre-consent: shell instance `shell_id` may fire
/// `api owner-shutdown` on this node (D4d's gate reads it back). Idempotent.
// [impl->REQ-CONSENT-2]
pub fn author_can_shutdown_grant(store: &mut GrantStore, shell_id: &str, node: &str) {
    store.grant(CAP_OWNER_SHUTDOWN, shell_id, node, None);
}

/// Author the `shell_wake_spawn_anywhere` pre-consent for `owner`: an
/// `instantiate-anywhere` row qualified to the shell-wake path. **Flag shape
/// only in M5** — the capability is reserved, so [`decide`] still refuses it
/// unconditionally (M5-PLAN scope decision 2); the row becomes live the day
/// instantiate-anywhere lands and leaves [`RESERVED_CAPABILITIES`]. This
/// deliberate authoring path is the one sanctioned way such a row exists
/// (the CLI refuses casual reserved-id authoring).
// [impl->REQ-CONSENT-2]
pub fn author_wake_spawn_anywhere_grant(store: &mut GrantStore, owner: &str, node: &str) {
    store.grant(CAP_INSTANTIATE_ANYWHERE, owner, node, Some(QUAL_SHELL_WAKE));
}

/// The user's answer to an escalation prompt.


// ─── Pre-consent flags (M5-D1c, REQ-CONSENT-2) ──────────────────────────────
//
// CONTEXT §Consent: the shell-manifest `can_shutdown` and endpoint-settings
// `shell_wake_spawn_anywhere` flags "are simply grants authored ahead of
// time via manifest/endpoint settings (same model, different authoring
// path)". These helpers ARE that authoring path; their call sites are the
// shell-spawn (D3) and wake-resolution (D4c) machinery reading the flags.

/// The qualifier narrowing a flag-authored `instantiate-anywhere` row to the
/// shell-wake path: `shell_wake_spawn_anywhere` pre-consents shells to
/// fresh-spawn **their owner, to wake it** — not general remote
/// instantiation (CONTEXT §Shell sleep/wake).
pub const QUAL_SHELL_WAKE: &str = "shell-wake";

/// Author the `can_shutdown` pre-consent: shell instance `shell_id` may fire
/// `api owner-shutdown` on this node (D4d's gate reads it back). Idempotent.
// [impl->REQ-CONSENT-2]
pub fn author_can_shutdown_grant(store: &mut GrantStore, shell_id: &str, node: &str) {
    store.grant(CAP_OWNER_SHUTDOWN, shell_id, node, None);
}

/// Author the `shell_wake_spawn_anywhere` pre-consent for `owner`: an
/// `instantiate-anywhere` row qualified to the shell-wake path. **Flag shape
/// only in M5** — the capability is reserved, so [`decide`] still refuses it
/// unconditionally (M5-PLAN scope decision 2); the row becomes live the day
/// instantiate-anywhere lands and leaves [`RESERVED_CAPABILITIES`]. This
/// deliberate authoring path is the one sanctioned way such a row exists
/// (the CLI refuses casual reserved-id authoring).
// [impl->REQ-CONSENT-2]
pub fn author_wake_spawn_anywhere_grant(store: &mut GrantStore, owner: &str, node: &str) {
    store.grant(CAP_INSTANTIATE_ANYWHERE, owner, node, Some(QUAL_SHELL_WAKE));
}

/// The user's answer to an escalation prompt.

+// ─── Pre-consent flags (M5-D1c, REQ-CONSENT-2) ──────────────────────────────

+// CONTEXT §Consent: the shell-manifest `can_shutdown` and endpoint-settings

+// `shell_wake_spawn_anywhere` flags "are simply grants authored ahead of

+// time via manifest/endpoint settings (same model, different authoring

+// path)". These helpers ARE that authoring path; their call sites are the

+// shell-spawn (D3) and wake-resolution (D4c) machinery reading the flags.

+/// The qualifier narrowing a flag-authored `instantiate-anywhere` row to the

+/// shell-wake path: `shell_wake_spawn_anywhere` pre-consents shells to

+/// fresh-spawn **their owner, to wake it** — not general remote

+/// instantiation (CONTEXT §Shell sleep/wake).

+pub const QUAL_SHELL_WAKE: &str = "shell-wake";

+/// Author the `can_shutdown` pre-consent: shell instance `shell_id` may fire

+/// `api owner-shutdown` on this node (D4d's gate reads it back). Idempotent.

+pub fn author_can_shutdown_grant(store: &mut GrantStore, shell_id: &str, node: &str) {

+    store.grant(CAP_OWNER_SHUTDOWN, shell_id, node, None);

+/// Author the `shell_wake_spawn_anywhere` pre-consent for `owner`: an

+/// `instantiate-anywhere` row qualified to the shell-wake path. **Flag shape

+/// only in M5** — the capability is reserved, so [`decide`] still refuses it

+/// unconditionally (M5-PLAN scope decision 2); the row becomes live the day

+/// instantiate-anywhere lands and leaves [`RESERVED_CAPABILITIES`]. This

+/// deliberate authoring path is the one sanctioned way such a row exists

+/// (the CLI refuses casual reserved-id authoring).

+pub fn author_wake_spawn_anywhere_grant(store: &mut GrantStore, owner: &str, node: &str) {

+    store.grant(CAP_INSTANTIATE_ANYWHERE, owner, node, Some(QUAL_SHELL_WAKE));

#[derive(Debug, Clone, Copy, PartialEq, Eq)]


<task-notification>
<task-id>bobjws0tj</task-id>
<tool-use-id>toolu_01SkRwaLxn63QmNMegJeaAgu</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bobjws0tj.output</output-file>
<status>completed</status>
<summary>Background command "Watch CI for D1a sha until FINAL conclusion" completed (exit code 0)</summary>
</task-notification>


// [unit->REQ-CONSENT-2] the escalation producer rides the consent notif

    // [unit->REQ-CONSENT-2] pre-consent flags author real rows through the
    // same store: can_shutdown satisfies the owner-shutdown gate immediately;
    // the wake-spawn-anywhere row is flag-shape-only — the reserved gate
    // still refuses it today (it goes live only when the capability lands).
    #[test]
    fn preconsent_flags_author_grants() {
        let mut s = GrantStore::default();

        author_can_shutdown_grant(&mut s, "OsNotify-0", "aa11");
        assert_eq!(
            decide(&s, CAP_OWNER_SHUTDOWN, "OsNotify-0", "aa11", None, None),
            GrantDecision::Allowed,
            "can_shutdown pre-consent satisfies the gate"
        );

        author_wake_spawn_anywhere_grant(&mut s, "ling", "aa11");
        assert!(
            s.granted(CAP_INSTANTIATE_ANYWHERE, "ling", "aa11", Some(QUAL_SHELL_WAKE)),
            "the row exists (the flag shape)"
        );
        assert_eq!(
            decide(&s, CAP_INSTANTIATE_ANYWHERE, "ling", "aa11", Some(QUAL_SHELL_WAKE), None),
            GrantDecision::RefusedReserved,
            "but the reserved gate still refuses it in M5"
        );

        // Both idempotent.
        author_can_shutdown_grant(&mut s, "OsNotify-0", "aa11");
        author_wake_spawn_anywhere_grant(&mut s, "ling", "aa11");
        assert_eq!(s.rows.len(), 2);
    }

    // [unit->REQ-CONSENT-2] the escalation producer rides the consent notif


    // [unit->REQ-CONSENT-2] pre-consent flags author real rows through the
    // same store: can_shutdown satisfies the owner-shutdown gate immediately;
    // the wake-spawn-anywhere row is flag-shape-only — the reserved gate
    // still refuses it today (it goes live only when the capability lands).
    #[test]
    fn preconsent_flags_author_grants() {
        let mut s = GrantStore::default();

        author_can_shutdown_grant(&mut s, "OsNotify-0", "aa11");
        assert_eq!(
            decide(&s, CAP_OWNER_SHUTDOWN, "OsNotify-0", "aa11", None, None),
            GrantDecision::Allowed,
            "can_shutdown pre-consent satisfies the gate"
        );

        author_wake_spawn_anywhere_grant(&mut s, "ling", "aa11");
        assert!(
            s.granted(CAP_INSTANTIATE_ANYWHERE, "ling", "aa11", Some(QUAL_SHELL_WAKE)),
            "the row exists (the flag shape)"
        );
        assert_eq!(
            decide(&s, CAP_INSTANTIATE_ANYWHERE, "ling", "aa11", Some(QUAL_SHELL_WAKE), None),
            GrantDecision::RefusedReserved,
            "but the reserved gate still refuses it in M5"
        );

        // Both idempotent.
        author_can_shutdown_grant(&mut s, "OsNotify-0", "aa11");
        author_wake_spawn_anywhere_grant(&mut s, "ling", "aa11");
        assert_eq!(s.rows.len(), 2);
    }

    // [unit->REQ-CONSENT-2] the escalation producer rides the consent notif

+    // [unit->REQ-CONSENT-2] pre-consent flags author real rows through the

+    // same store: can_shutdown satisfies the owner-shutdown gate immediately;

+    // the wake-spawn-anywhere row is flag-shape-only — the reserved gate

+    // still refuses it today (it goes live only when the capability lands).

+    fn preconsent_flags_author_grants() {

+        let mut s = GrantStore::default();

+        author_can_shutdown_grant(&mut s, "OsNotify-0", "aa11");

+            decide(&s, CAP_OWNER_SHUTDOWN, "OsNotify-0", "aa11", None, None),

+            "can_shutdown pre-consent satisfies the gate"

+        author_wake_spawn_anywhere_grant(&mut s, "ling", "aa11");

+            s.granted(CAP_INSTANTIATE_ANYWHERE, "ling", "aa11", Some(QUAL_SHELL_WAKE)),

+            "the row exists (the flag shape)"

+            decide(&s, CAP_INSTANTIATE_ANYWHERE, "ling", "aa11", Some(QUAL_SHELL_WAKE), None),

+            GrantDecision::RefusedReserved,

+            "but the reserved gate still refuses it in M5"

// kind and first-fires at the most-recently-active live endpoint; the

// fired body parses back to the original ask.


Prompt:
<task-notification>
<task-id>bobjws0tj</task-id>
<tool-use-id>toolu_01SkRwaLxn63QmNMegJeaAgu</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bobjws0tj.output</output-file>
<status>completed</status>
<summary>Background command "Watch CI for D1a sha until FINAL conclusion" completed (exit code 0)</summary>
</task-notification>


1	ci: cancelled
2

ci: cancelled


PowerShell

cargo test -p spt-daemon --lib grants 2>&1 | Select-Object -Last 11; cargo clippy -p spt-daemon --all-targets -- -D warnings 2>&1 | Select-Object -Last 1; traceable-reqs check 2>&1 | Select-Object -First 1

Run D1c tests + clippy + traceability


running 7 tests
test grants::tests::no_session_escalates_with_none_target ... ok
test grants::tests::preconsent_flags_author_grants ... ok
test grants::tests::escalation_answers_apply ... ok
test grants::tests::reserved_capabilities_refuse_even_when_granted ... ok
test grants::tests::escalation_ask_body_roundtrip ... ok
test grants::tests::ungranted_escalates_granted_allows ... ok
test grants::tests::escalation_notif_fires_to_most_recent_and_roundtrips ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 133 filtered out; finished in 0.15s

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.33s
134 requirements: 134 complete, 0 incomplete, 0 findings


All green. D1c commit — note in toml rationale, push, then watch FINAL (no more push churn):


Edit

D1c extends with the pre-consent flag authoring paths. int = the D3d spawn-gate consumer E2E

D1c: the pre-consent flag authoring paths (author_can_shutdown_grant live-now; author_wake_spawn_anywhere_grant flag-shape-only — qualified shell-wake row the reserved gate still refuses until instantiate-anywhere lands). int = the D3d spawn-gate consumer E2E


title = "Interactive consent escalation: an ungated high-risk action routes a consent prompt to the user's most-recently-active session; allow-once / allow-always (writes a grant) / deny; pre-consent flags (can_shutdown, shell_wake_spawn_anywhere) author grants via manifest/settings (CONTEXT Consent & security gates)"

-required_stages = ["impl", "unit"]  # activated M5-D1b: spt-daemon::grants escalation — EscalationAsk lossless body roundtrip over the consent-kind notif (produce_escalation_notif rides produce_and_first_fire to the most-recently-active session) + apply_escalation_answer (allow-once proceeds unpersisted / allow-always writes the exact tuple / deny persists nothing). D1c extends with the pre-consent flag authoring paths. int = the D3d spawn-gate consumer E2E

+required_stages = ["impl", "unit"]  # activated M5-D1b: spt-daemon::grants escalation — EscalationAsk lossless body roundtrip over the consent-kind notif (produce_escalation_notif rides produce_and_first_fire to the most-recently-active session) + apply_escalation_answer (allow-once proceeds unpersisted / allow-always writes the exact tuple / deny persists nothing). D1c: the pre-consent flag authoring paths (author_can_shutdown_grant live-now; author_wake_spawn_anywhere_grant flag-shape-only — qualified shell-wake row the reserved gate still refuses until instantiate-anywhere lands). int = the D3d spawn-gate consumer E2E

title = "Presence resolution: the presence datum (last_active_node, last_active_endpoint, ts) gossiped subnet-wide via the agent-interaction heartbeat (rides registry distribution, visibility-gated) + one first-class most-recently-active resolution API consumed by notif first-fire, update-consent delivery, consent escalation, and shell wake resolution (M5 scope decision 1: resolution only — the PresenceChannel endpoint stays deferred)"


PowerShell

git add crates/spt-daemon/src/grants.rs traceable-reqs.toml && git commit -m @'
feat(daemon): M5-D1c pre-consent flag authoring paths

The CONTEXT pre-consent flags become real authoring paths into the grant
store (same model, different authoring path): author_can_shutdown_grant
(shell manifest can_shutdown -> owner-shutdown row, live immediately for
the D4d gate) and author_wake_spawn_anywhere_grant
(shell_wake_spawn_anywhere -> instantiate-anywhere row qualified
shell-wake). The latter is flag shape only in M5: the reserved gate still
refuses it unconditionally; the row goes live the day instantiate-anywhere
leaves RESERVED_CAPABILITIES. This deliberate path is the one sanctioned
way a reserved-id row exists (the CLI refuses casual authoring).

D1 complete: grant store (D1a) + escalation (D1b) + pre-consent flags (D1c).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@ && git push

Commit and push M5-D1c slice


warning: in the working copy of 'crates/spt-daemon/src/grants.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 5a96c3e] feat(daemon): M5-D1c pre-consent flag authoring paths
 2 files changed, 66 insertions(+), 1 deletion(-)
To https://github.com/SaberMage/spt-core.git
   eac1524..5a96c3e  dev-freeform -> dev-freeform


$sha = git rev-parse 5a96c3e; for ($i=0; $i -lt 60; $i++) { $r = gh run list --commit $sha --json status,conclusion,workflowName | ConvertFrom-Json; if ($r.Count -gt 0 -and ($r | Where-Object { $_.status -ne "completed" }).Count -eq 0) { $r | ForEach-Object { "$($_.workflowName): $($_.conclusion)" }; break }; Start-Sleep -Seconds 20 }

Watch CI for D1-complete sha until FINAL conclusion


Command running in background with ID: bjktpeb8s. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bjktpeb8s.output. You will be notified when it completes. To check interim output, use Read on that file path.


No matches found


8:2. **Endpoint IDs are adapter-agnostic.** `ling` can run under `claude-spt`, then later under `spt-codex` or `spt-pi`. Perches and context key on bare `endpoint_id`; the currently-running `adapter_name` is a *property of the instance* recorded in `info.json`, never a path segment. **At most one instance of an ID per node** — a second attempt (any adapter) is rejected.
23:      meta.json                  endpoint identity metadata: type, ordered adapter history, created
30:      info.json                  identity, type, status, CURRENT adapter_name (+ capability set if Shell)
34:      shells/<adapter>-<n>/      Shell perches owned by this endpoint (GameRobot-0, GameRobot-1…)
35:        info.json                type=Shell, owner, adapter_name, status (online|offline), alias?
40:      shells/<adapter>-<n>.jsonl     shell-link command/text/sensory exchange log
44:  adapters/                      registered adapter manifests / pointers
145:- Holds the clobber-sensitive, node-specific state: `info.json` (identity, type, current `adapter_name`, status), the SQLite `spool.db`, the `status` marker, and `nested/` Worker/Psyche child perches.
148:## Endpoint metadata & adapter history (`tracked/agents/<id>/meta.json`)
150:- Records the endpoint's durable identity: type, creation, and an **ordered adapter history** (most-recent first). Synced, so any node knows it.
151:- Drives the **resume UX**: the latest adapter is the default resume offer, the next-latest is second, … the oldest is second-to-last, and "choose a different adapter" is the final option.
152:- The *currently-running* `adapter_name` lives in node-local `perches/<id>/info.json`; the *history* is endpoint identity in `tracked/`.
157:- **Nested perch under the owner:** `perches/<endpoint_id>/shells/<adapter>-<n>/` — one agent owns many shells. `info.json` carries `type=Shell`, `owner`, `adapter_name`, `status` (online|offline), and an optional `alias` — **not** the capability set (that lives in the shell adapter manifest, resolved by `adapter_name`). `spool.db` holds the durable command + text/file channels; the sensory channel is REST-only and never spooled.
160:- **Logs** in node-local `history/<endpoint_id>/shells/<adapter>-<n>.jsonl`.
161:- **Adapter/platform-bound, not adapter-agnostic** — capability surface comes from the providing shell adapter; it does not move harnesses.


36-        spool.db                 command + durable text/file (sensory is REST-only, unspooled)
37-  history/                       node-local logs (mirrors perch structure)
38-    <endpoint_id>/
39-      sessions/<session_id>.jsonl    agent Path-B native logs
40-      shells/<adapter>-<n>.jsonl     shell-link command/text/sensory exchange log
41-  registry/                      ── ephemeral ── local cache of the subnet registry
42-  daemon/                        ── ephemeral ── brain rehydration state, broker handle metadata
43-  updates/                       ── ephemeral ── staged binaries pending handoff
44:  adapters/                      registered adapter manifests / pointers
45-```
46-
47-### Registry snapshots + the resource-advertisement blurb (D9)
48-
49-<!-- [doc->REQ-INST-14] -->
50-
51-The daemon's live per-subnet registries ([`RegistryHost`]) mirror to **atomic
52-JSON snapshots at `identity/registry/<subnet>.json`** for out-of-process


crates\spt-runtime\src\runtime.rs:114:/// portable). Single quotes are literal — adapters quote with `"`.
crates\spt-runtime\src\manifest.rs:74:/// surface (MANIFEST §Shell adapters).
crates\spt\tests\contract_e2e.rs:82:    let manifest_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../adapters/mock/manifest.toml");
crates\spt\tests\contract_e2e.rs:219:    let manifest_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../adapters/mock/manifest.toml");
crates\spt-live\src\psyche.rs:67:/// envelope (CONTEXT §spawn-psyche seam). Consistent across adapters so the
crates\spt\src\api\reporting.rs:222:/// the surface is complete and adapters can bind it.
crates\spt-daemon\src\adapter_update.rs:6://! ripple over its registered adapters. Each adapter declares how it updates in
crates\spt-daemon\src\adapter_update.rs:132:/// adapters-after-self half; the ordering is the caller's contract, preserved
crates\spt-daemon\src\adapter_update.rs:135:pub fn conduct_ripple(adapters: &[AdapterRipple<'_>]) -> Vec<(String, AdapterUpdateOutcome)> {
crates\spt-daemon\src\adapter_update.rs:136:    adapters
crates\spt-daemon\src\adapter_update.rs:277:        let adapters = vec![
crates\spt-daemon\src\adapter_update.rs:281:        let out = conduct_ripple(&adapters);


1	//! Runtime-manifest schema — the typed form of [`docs/MANIFEST.md`].
2	//!
3	//! A manifest declares only what *varies per harness*; command templates are
4	//! **opaque strings** spt-core never parses (MANIFEST §Principle). This module
5	//! is the parse+validate front door: [`Manifest::from_toml_str`] deserializes
6	//! the TOML and runs [`Manifest::validate`], returning a [`ManifestError`] with
7	//! a human-readable message instead of panicking on bad adapter input.
8	//!
9	//! Closed vocabularies (adapter kind, history strategy, env direction, inject
10	//! method, identity source, update avenue, shell broadcast/receipt) are modeled
11	//! as enums so an unknown variant is a parse error pointing at the bad value —
12	//! the "helpful errors for adapter authors" open sub-item (MANIFEST §Open).
13	//!
14	//! Scope (M2a/T1): every section is *declared and parsed*. Executing the
15	//! lifecycle sections (`[session.psyche_*]`, commune/signoff dirs, `[history]`
16	//! source, `[update]` conduct) is M2b/M3 — here they only round-trip.
17	//
18	// [impl->REQ-MANIFEST-1]
19
20	use serde::{Deserialize, Serialize};
21	use std::collections::BTreeMap;
22	use std::fmt;
23
24	/// A fully-parsed runtime manifest. Top-level sections map 1:1 to the
25	/// `docs/MANIFEST.md` tables; all but `[adapter]` are optional so a minimal
26	/// adapter declares only its header.
27	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28	pub struct Manifest {
29	    pub adapter: Adapter,
30	    /// `[hooks.<event>]` — inbound hook table, keyed by harness event name.
31	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
32	    pub hooks: BTreeMap<String, Hook>,
33	    /// `[session]` — watched-dir keys plus the `[session.<role>]` templates.
34	    #[serde(default, skip_serializing_if = "Session::is_empty")]
35	    pub session: Session,
36	    /// `[env.<VAR>]` — env-var inject/read table.
37	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
38	    pub env: BTreeMap<String, EnvVar>,
39	    #[serde(default, skip_serializing_if = "Option::is_none")]
40	    pub history: Option<History>,
41	    #[serde(default, skip_serializing_if = "Option::is_none")]
42	    pub inject: Option<Inject>,
43	    #[serde(default, skip_serializing_if = "Option::is_none")]
44	    pub identity: Option<Identity>,
45	    #[serde(default, skip_serializing_if = "Option::is_none")]
46	    pub update: Option<Update>,
47	    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
48	    #[serde(default, skip_serializing_if = "Option::is_none")]
49	    pub shell: Option<Shell>,
50	    /// `[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The
51	    /// adapter supplies the boundary + tool patterns; spt-core only *runs* them
52	    /// over the broker PTY byte stream (the no-built-in-parser rule — identical to
53	    /// opaque command templates). Absent ⇒ the digest is unavailable for this
54	    /// adapter.
55	    #[serde(default, skip_serializing_if = "Option::is_none")]
56	    pub pty_digest: Option<PtyDigest>,
57	}
58
59	/// `[adapter]` — the manifest header, readable before any update (compat gate).
60	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61	pub struct Adapter {
62	    pub name: String,
63	    #[serde(default)]
64	    pub kind: AdapterKind,
65	    pub version: String,
66	    /// Lowest spt-core version this adapter tolerates (compat gate).
67	    pub min_spt_core_version: String,
68	    /// Endpoint types this adapter can host (`LiveAgent`, `Worker`, …).
69	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70	    pub hostable_types: Vec<String>,
71	}
72
73	/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven
74	/// surface (MANIFEST §Shell adapters).
75	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
76	#[serde(rename_all = "snake_case")]
77	pub enum AdapterKind {
78	    #[default]
79	    Harness,
80	    Shell,
81	}
82
83	/// `[hooks.<event>]` — one harness event → the `api` command it fires, the
84	/// stdin fields it maps, and whether it can surface context to the agent.
85	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86	pub struct Hook {
87	    /// Opaque `api …` command line the harness invokes for this event.
88	    pub fires: String,
89	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90	    pub reads: Vec<String>,
91	    /// Whether this hook can inject context (false ⇒ sentinel/relay fallback).
92	    #[serde(default)]
93	    pub can_inject: bool,
94	}
95
96	/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
97	/// with the fixed set of `[session.<role>]` command templates.
98	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
99	pub struct Session {
100	    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
101	    // only the directory is adapter-declared (MANIFEST §watched dirs).
102	    #[serde(default, skip_serializing_if = "Option::is_none")]
103	    pub commune_dir: Option<String>,
104	    #[serde(default, skip_serializing_if = "Option::is_none")]
105	    pub signoff_dir: Option<String>,
106	    // Role templates (`[session.<role>]`). `self` is the agent's own session;
107	    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
108	    // *executed* in M2b.
109	    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
110	    pub self_: Option<SessionRole>,
111	    #[serde(default, skip_serializing_if = "Option::is_none")]
112	    pub psyche_init: Option<SessionRole>,
113	    #[serde(default, skip_serializing_if = "Option::is_none")]
114	    pub psyche_resume: Option<SessionRole>,
115	    #[serde(default, skip_serializing_if = "Option::is_none")]
116	    pub echo_commune: Option<SessionRole>,
117	    #[serde(default, skip_serializing_if = "Option::is_none")]
118	    pub signoff: Option<SessionRole>,
119	    /// `[session.notif]` — the endpoint-native notification render
120	    /// (ADR-0007's `notif_command` seam, REQ-NOTIF-2): an OS toast, a
121	    /// GameRobot `alert-symbol`, anything the adapter can run. Spawned
122	    /// detached when a notif surfaces at this endpoint, combinable with the
123	    /// agent-surface delivery. Keys spt-core fills: `{notif_id}`,
124	    /// `{notif_from}`, `{notif_subnet}`, `{notif_body}`.
125	    // [impl->REQ-NOTIF-2]
126	    #[serde(default, skip_serializing_if = "Option::is_none")]
127	    pub notif: Option<SessionRole>,
128	}
129
130	impl Session {
131	    /// True when no dirs and no roles are declared (skip-serialize helper).
132	    pub fn is_empty(&self) -> bool {
133	        self.commune_dir.is_none()
134	            && self.signoff_dir.is_none()
135	            && self.self_.is_none()
136	            && self.psyche_init.is_none()
137	            && self.psyche_resume.is_none()
138	            && self.echo_commune.is_none()
139	            && self.signoff.is_none()
140	            && self.notif.is_none()

//! Runtime-manifest schema — the typed form of [`docs/MANIFEST.md`].
//!
//! A manifest declares only what *varies per harness*; command templates are
//! **opaque strings** spt-core never parses (MANIFEST §Principle). This module
//! is the parse+validate front door: [`Manifest::from_toml_str`] deserializes
//! the TOML and runs [`Manifest::validate`], returning a [`ManifestError`] with
//! a human-readable message instead of panicking on bad adapter input.
//!
//! Closed vocabularies (adapter kind, history strategy, env direction, inject
//! method, identity source, update avenue, shell broadcast/receipt) are modeled
//! as enums so an unknown variant is a parse error pointing at the bad value —
//! the "helpful errors for adapter authors" open sub-item (MANIFEST §Open).
//!
//! Scope (M2a/T1): every section is *declared and parsed*. Executing the
//! lifecycle sections (`[session.psyche_*]`, commune/signoff dirs, `[history]`
//! source, `[update]` conduct) is M2b/M3 — here they only round-trip.
//
// [impl->REQ-MANIFEST-1]

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;

/// A fully-parsed runtime manifest. Top-level sections map 1:1 to the
/// `docs/MANIFEST.md` tables; all but `[adapter]` are optional so a minimal
/// adapter declares only its header.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Manifest {
    pub adapter: Adapter,
    /// `[hooks.<event>]` — inbound hook table, keyed by harness event name.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub hooks: BTreeMap<String, Hook>,
    /// `[session]` — watched-dir keys plus the `[session.<role>]` templates.
    #[serde(default, skip_serializing_if = "Session::is_empty")]
    pub session: Session,
    /// `[env.<VAR>]` — env-var inject/read table.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub env: BTreeMap<String, EnvVar>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub history: Option<History>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub inject: Option<Inject>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub identity: Option<Identity>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub update: Option<Update>,
    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shell: Option<Shell>,
    /// `[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The
    /// adapter supplies the boundary + tool patterns; spt-core only *runs* them
    /// over the broker PTY byte stream (the no-built-in-parser rule — identical to
    /// opaque command templates). Absent ⇒ the digest is unavailable for this
    /// adapter.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pty_digest: Option<PtyDigest>,
}

/// `[adapter]` — the manifest header, readable before any update (compat gate).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Adapter {
    pub name: String,
    #[serde(default)]
    pub kind: AdapterKind,
    pub version: String,
    /// Lowest spt-core version this adapter tolerates (compat gate).
    pub min_spt_core_version: String,
    /// Endpoint types this adapter can host (`LiveAgent`, `Worker`, …).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub hostable_types: Vec<String>,
}

/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven
/// surface (MANIFEST §Shell adapters).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AdapterKind {
    #[default]
    Harness,
    Shell,
}

/// `[hooks.<event>]` — one harness event → the `api` command it fires, the
/// stdin fields it maps, and whether it can surface context to the agent.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Hook {
    /// Opaque `api …` command line the harness invokes for this event.
    pub fires: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub reads: Vec<String>,
    /// Whether this hook can inject context (false ⇒ sentinel/relay fallback).
    #[serde(default)]
    pub can_inject: bool,
}

/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
/// with the fixed set of `[session.<role>]` command templates.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Session {
    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
    // only the directory is adapter-declared (MANIFEST §watched dirs).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commune_dir: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff_dir: Option<String>,
    // Role templates (`[session.<role>]`). `self` is the agent's own session;
    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
    // *executed* in M2b.
    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
    pub self_: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_init: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_resume: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub echo_commune: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff: Option<SessionRole>,
    /// `[session.notif]` — the endpoint-native notification render
    /// (ADR-0007's `notif_command` seam, REQ-NOTIF-2): an OS toast, a
    /// GameRobot `alert-symbol`, anything the adapter can run. Spawned
    /// detached when a notif surfaces at this endpoint, combinable with the
    /// agent-surface delivery. Keys spt-core fills: `{notif_id}`,
    /// `{notif_from}`, `{notif_subnet}`, `{notif_body}`.
    // [impl->REQ-NOTIF-2]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub notif: Option<SessionRole>,
}

impl Session {
    /// True when no dirs and no roles are declared (skip-serialize helper).
    pub fn is_empty(&self) -> bool {
        self.commune_dir.is_none()
            && self.signoff_dir.is_none()
            && self.self_.is_none()
            && self.psyche_init.is_none()
            && self.psyche_resume.is_none()
            && self.echo_commune.is_none()
            && self.signoff.is_none()
            && self.notif.is_none()


Grep

pub struct Update|pub enum UpdateAvenue|avenue|file_pull|delegated


10://! method, identity source, update avenue, shell broadcast/receipt) are modeled
270:pub struct Update {
271:    pub avenue: UpdateAvenue,
272:    /// `delegated` avenue: the command spt-core delegates to.
275:    /// `file_pull` avenue: source repo.
278:    /// `file_pull` avenue: path selector.
281:    /// `file_pull` avenue: the adapter's Ed25519 **content-signing public key**
285:    /// release key stays scoped to spt-core. **Required for `file_pull`** (there
286:    /// are bytes to verify); not applicable to `delegated` (opaque updater).
289:    /// `delegated` avenue: the adapter attests its own updater verifies the
291:    /// signatures). spt-core cannot see a delegated updater's bytes, so it
292:    /// delegates the trust **only** when this is set; an unattested delegated
309:pub enum UpdateAvenue {
594:        // update avenue ↔ required fields.
596:            match u.avenue {
598:                    return err("[update] avenue = \"delegated\" requires `command`".into());
601:                    return err("[update] avenue = \"file_pull\" requires `repo`".into());
605:                    // verifiable, so a file_pull adapter must declare its key.
607:                        "[update] avenue = \"file_pull\" requires `signing_key` (adapter content signing)".into(),
670:avenue = "delegated"
730:        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
740:    // [unit->REQ-UPD-5] adapter content signing: a `file_pull` avenue must declare
742:    // `delegated` avenue carries the `self_verifies` attest instead. Both shapes
745:    fn update_avenue_content_signing_fields() {
746:        // file_pull without signing_key → rejected.

[Showing results with pagination = limit: 25]


262	    /// Discovered after spawn (process-tree / wrapper handoff).
263	    PostSpawn,
264	    /// Injected as a UUID the harness echoes back.
265	    UuidInject,
266	}
267
268	/// `[update]` — adapter self-update directive (parsed in M2a; conducted in M3).
269	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270	pub struct Update {
271	    pub avenue: UpdateAvenue,
272	    /// `delegated` avenue: the command spt-core delegates to.
273	    #[serde(default, skip_serializing_if = "Option::is_none")]
274	    pub command: Option<String>,
275	    /// `file_pull` avenue: source repo.
276	    #[serde(default, skip_serializing_if = "Option::is_none")]
277	    pub repo: Option<String>,
278	    /// `file_pull` avenue: path selector.
279	    #[serde(default, skip_serializing_if = "Option::is_none")]
280	    pub path_regex: Option<String>,
281	    /// `file_pull` avenue: the adapter's Ed25519 **content-signing public key**
282	    /// (64 hex chars / 32 bytes). spt-core verifies a pulled payload against this
283	    /// per-adapter key before applying it (REQ-UPD-5 adapter content signing,
284	    /// ADR-0004 §D) — the adapter author signs their own releases; spt-core's
285	    /// release key stays scoped to spt-core. **Required for `file_pull`** (there
286	    /// are bytes to verify); not applicable to `delegated` (opaque updater).
287	    #[serde(default, skip_serializing_if = "Option::is_none")]
288	    pub signing_key: Option<String>,
289	    /// `delegated` avenue: the adapter attests its own updater verifies the
290	    /// content it installs (e.g. `claude.exe plugin update` checks its own
291	    /// signatures). spt-core cannot see a delegated updater's bytes, so it
292	    /// delegates the trust **only** when this is set; an unattested delegated
293	    /// update is skipped as unverifiable (REQ-UPD-5).
294	    #[serde(default)]
295	    pub self_verifies: bool,
296	    /// Verify spt-core satisfies `min_spt_core_version` before/after.
297	    #[serde(default)]
298	    pub version_check: bool,
299	    /// Optional inverse of install — run by `spt adapter remove` once the adapter
300	    /// is quiesced (the mirror of `spt adapter add`, which reuses this section as
301	    /// the install mechanism). Absent ⇒ spt-core's default cleanup. (Modeled in
302	    /// M2a; conducted with adapter-registration later.)
303	    #[serde(default, skip_serializing_if = "Option::is_none")]
304	    pub uninstall: Option<String>,
305	}
306
307	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
308	#[serde(rename_all = "snake_case")]
309	pub enum UpdateAvenue {
310	    /// Delegate to the adapter's own updater (e.g. `claude plugin update`).
311	    Delegated,
312	    /// spt-core pulls files from a repo.
313	    FilePull,
314	}
315
316	/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).
317	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
318	pub struct Shell {
319	    /// Broker-launched opaque spawn command.
320	    pub spawn: String,
321	    /// Ephemeral ⇒ no offline perch + no history retention.

    /// Discovered after spawn (process-tree / wrapper handoff).
    PostSpawn,
    /// Injected as a UUID the harness echoes back.
    UuidInject,
}

/// `[update]` — adapter self-update directive (parsed in M2a; conducted in M3).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Update {
    pub avenue: UpdateAvenue,
    /// `delegated` avenue: the command spt-core delegates to.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command: Option<String>,
    /// `file_pull` avenue: source repo.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repo: Option<String>,
    /// `file_pull` avenue: path selector.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path_regex: Option<String>,
    /// `file_pull` avenue: the adapter's Ed25519 **content-signing public key**
    /// (64 hex chars / 32 bytes). spt-core verifies a pulled payload against this
    /// per-adapter key before applying it (REQ-UPD-5 adapter content signing,
    /// ADR-0004 §D) — the adapter author signs their own releases; spt-core's
    /// release key stays scoped to spt-core. **Required for `file_pull`** (there
    /// are bytes to verify); not applicable to `delegated` (opaque updater).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signing_key: Option<String>,
    /// `delegated` avenue: the adapter attests its own updater verifies the
    /// content it installs (e.g. `claude.exe plugin update` checks its own
    /// signatures). spt-core cannot see a delegated updater's bytes, so it
    /// delegates the trust **only** when this is set; an unattested delegated
    /// update is skipped as unverifiable (REQ-UPD-5).
    #[serde(default)]
    pub self_verifies: bool,
    /// Verify spt-core satisfies `min_spt_core_version` before/after.
    #[serde(default)]
    pub version_check: bool,
    /// Optional inverse of install — run by `spt adapter remove` once the adapter
    /// is quiesced (the mirror of `spt adapter add`, which reuses this section as
    /// the install mechanism). Absent ⇒ spt-core's default cleanup. (Modeled in
    /// M2a; conducted with adapter-registration later.)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub uninstall: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UpdateAvenue {
    /// Delegate to the adapter's own updater (e.g. `claude plugin update`).
    Delegated,
    /// spt-core pulls files from a repo.
    FilePull,
}

/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Shell {
    /// Broker-launched opaque spawn command.
    pub spawn: String,
    /// Ephemeral ⇒ no offline perch + no history retention.


1	//! Adapter ripple-update conduct (C3) — spt-core updates the whole stack: self
2	//! first, then each registered adapter via its manifest `[update]` avenue, with
3	//! **adapter content signing** (REQ-UPD-5, REQ-SEAM-UPDATE, ADR-0004 §D).
4	//!
5	//! After spt-core's own update completes (the C0–C2 engine), it conducts a
6	//! ripple over its registered adapters. Each adapter declares how it updates in
7	//! its manifest `[update]` block ([`Update`]); this module turns that directive
8	//! into a per-adapter [`AdapterUpdateOutcome`], dispatched by avenue:
9	//!
10	//! - **`file_pull`** — spt-core controls the bytes, so it *verifies* them: the
11	//!   pulled payload is checked against the adapter's **own** Ed25519 key (declared
12	//!   in the manifest — the adapter author signs their releases; spt-core's release
13	//!   key stays scoped to spt-core) plus the SHA-256 artifact binding. Authentic +
14	//!   intact ⇒ [`AdapterUpdateOutcome::Verified`]; otherwise
15	//!   [`AdapterUpdateOutcome::Rejected`].
16	//! - **`delegated`** — the adapter's own updater installs the content (e.g.
17	//!   `claude.exe plugin update`); spt-core never sees those bytes, so there is
18	//!   nothing for it to hash. It delegates the trust **only** when the adapter
19	//!   attests `self_verifies` (its updater checks its own signatures) ⇒
20	//!   [`AdapterUpdateOutcome::Delegate`]; an unattested delegated update is
21	//!   [`AdapterUpdateOutcome::Skipped`] as unverifiable.
22	//!
23	//! The actual byte transport for `file_pull` (the network pull) is M4
24	//! (`REQ-UPD-1`); v1 receives the payload via self-fetch / out-of-band and feeds
25	//! it here. This module is the **decision + verification** layer — it does not
26	//! itself fetch or exec.
27	// [impl->REQ-UPD-5]
28	// [impl->REQ-SEAM-UPDATE]
29
30	use spt_runtime::manifest::{Update, UpdateAvenue};
31
32	use crate::release::{
33	    parse_verifying_key, verify_artifact, verify_signature, ReleaseMetadata, RejectReason,
34	    SignedRelease,
35	};
36
37	/// A pulled `file_pull` payload awaiting verification: the adapter's signed
38	/// release metadata plus the actual bytes it attests to. Supplied by the
39	/// self-fetch / out-of-band layer (the network pull is M4).
40	#[derive(Debug, Clone)]
41	pub struct FilePullPayload {
42	    /// The adapter's signed release metadata (signed with the adapter's key).
43	    pub signed: SignedRelease,
44	    /// The pulled artifact bytes (hashed + matched against the signed digest).
45	    pub bytes: Vec<u8>,
46	}
47
48	/// Why an adapter's ripple-update was not applied as a verified content update.
49	#[derive(Debug, Clone, PartialEq, Eq)]
50	pub enum SkipReason {
51	    /// The adapter declares no `[update]` avenue — nothing to conduct.
52	    NoAvenue,
53	    /// A `delegated` adapter did not attest `self_verifies`, so its content is
54	    /// unverifiable by spt-core — skipped rather than trusted blindly.
55	    UnverifiableDelegated,
56	    /// A `delegated` adapter attested `self_verifies` but declared no `command`.
57	    DelegatedMissingCommand,
58	    /// A `file_pull` adapter is missing its `signing_key` (should be caught at
59	    /// manifest validation, but the conductor never trusts an unverifiable pull).
60	    FilePullMissingKey,
61	    /// A `file_pull` adapter had no payload to verify yet (the pull has not run).
62	    NoPayload,
63	}
64
65	/// The conductor's verdict for one adapter (REQ-UPD-5).
66	#[derive(Debug, Clone, PartialEq, Eq)]
67	pub enum AdapterUpdateOutcome {
68	    /// `file_pull`: the payload is authentic (adapter key) + intact (digest) and
69	    /// ready to apply. Carries the verified metadata.
70	    Verified(ReleaseMetadata),
71	    /// `delegated` + attested: run the adapter's own updater (the trusted command).
72	    Delegate(String),
73	    /// `file_pull`: the payload failed verification — never applied.
74	    Rejected(RejectReason),
75	    /// The adapter was skipped (see [`SkipReason`]) — no update applied.
76	    Skipped(SkipReason),
77	}
78
79	/// Decide one adapter's ripple-update from its `[update]` directive + (for
80	/// `file_pull`) the pulled payload (REQ-UPD-5). Pure: no fetch, no exec, no clock
81	/// — the caller supplies the payload and acts on the verdict.
82	// [impl->REQ-UPD-5]
83	// [impl->REQ-SEAM-UPDATE]
84	pub fn plan_adapter_update(
85	    update: &Update,
86	    payload: Option<&FilePullPayload>,
87	) -> AdapterUpdateOutcome {
88	    match update.avenue {
89	        UpdateAvenue::FilePull => {
90	            let Some(key_hex) = update.signing_key.as_deref() else {
91	                return AdapterUpdateOutcome::Skipped(SkipReason::FilePullMissingKey);
92	            };
93	            let key = match parse_verifying_key(key_hex) {
94	                Ok(k) => k,
95	                Err(e) => return AdapterUpdateOutcome::Rejected(e),
96	            };
97	            let Some(payload) = payload else {
98	                return AdapterUpdateOutcome::Skipped(SkipReason::NoPayload);
99	            };
100	            // Authentic (adapter key) + intact (digest) — the two halves of
101	            // adapter content signing.
102	            match verify_signature(&payload.signed, &key) {
103	                Ok(meta) => match verify_artifact(&meta, &payload.bytes) {
104	                    Ok(()) => AdapterUpdateOutcome::Verified(meta),
105	                    Err(e) => AdapterUpdateOutcome::Rejected(e),
106	                },
107	                Err(e) => AdapterUpdateOutcome::Rejected(e),
108	            }
109	        }
110	        UpdateAvenue::Delegated => {
111	            if !update.self_verifies {
112	                return AdapterUpdateOutcome::Skipped(SkipReason::UnverifiableDelegated);
113	            }
114	            match update.command.as_deref() {
115	                Some(cmd) => AdapterUpdateOutcome::Delegate(cmd.to_string()),
116	                None => AdapterUpdateOutcome::Skipped(SkipReason::DelegatedMissingCommand),
117	            }
118	        }
119	    }
120	}
121
122	/// An adapter to ripple: its id, its `[update]` directive, and (for `file_pull`)
123	/// its pulled payload.
124	pub struct AdapterRipple<'a> {
125	    pub id: String,
126	    pub update: &'a Update,
127	    pub payload: Option<FilePullPayload>,
128	}
129
130	/// Conduct the adapter ripple in **registration order** (REQ-UPD-5). spt-core's
131	/// own update runs *before* this (the C0–C2 self-update) — this is the
132	/// adapters-after-self half; the ordering is the caller's contract, preserved
133	/// here by mapping in order. Returns each adapter's id + verdict.
134	// [impl->REQ-UPD-5]
135	pub fn conduct_ripple(adapters: &[AdapterRipple<'_>]) -> Vec<(String, AdapterUpdateOutcome)> {
136	    adapters
137	        .iter()
138	        .map(|a| (a.id.clone(), plan_adapter_update(a.update, a.payload.as_ref())))
139	        .collect()
140	}

//! Adapter ripple-update conduct (C3) — spt-core updates the whole stack: self
//! first, then each registered adapter via its manifest `[update]` avenue, with
//! **adapter content signing** (REQ-UPD-5, REQ-SEAM-UPDATE, ADR-0004 §D).
//!
//! After spt-core's own update completes (the C0–C2 engine), it conducts a
//! ripple over its registered adapters. Each adapter declares how it updates in
//! its manifest `[update]` block ([`Update`]); this module turns that directive
//! into a per-adapter [`AdapterUpdateOutcome`], dispatched by avenue:
//!
//! - **`file_pull`** — spt-core controls the bytes, so it *verifies* them: the
//!   pulled payload is checked against the adapter's **own** Ed25519 key (declared
//!   in the manifest — the adapter author signs their releases; spt-core's release
//!   key stays scoped to spt-core) plus the SHA-256 artifact binding. Authentic +
//!   intact ⇒ [`AdapterUpdateOutcome::Verified`]; otherwise
//!   [`AdapterUpdateOutcome::Rejected`].
//! - **`delegated`** — the adapter's own updater installs the content (e.g.
//!   `claude.exe plugin update`); spt-core never sees those bytes, so there is
//!   nothing for it to hash. It delegates the trust **only** when the adapter
//!   attests `self_verifies` (its updater checks its own signatures) ⇒
//!   [`AdapterUpdateOutcome::Delegate`]; an unattested delegated update is
//!   [`AdapterUpdateOutcome::Skipped`] as unverifiable.
//!
//! The actual byte transport for `file_pull` (the network pull) is M4
//! (`REQ-UPD-1`); v1 receives the payload via self-fetch / out-of-band and feeds
//! it here. This module is the **decision + verification** layer — it does not
//! itself fetch or exec.
// [impl->REQ-UPD-5]
// [impl->REQ-SEAM-UPDATE]

use spt_runtime::manifest::{Update, UpdateAvenue};

use crate::release::{
    parse_verifying_key, verify_artifact, verify_signature, ReleaseMetadata, RejectReason,
    SignedRelease,
};

/// A pulled `file_pull` payload awaiting verification: the adapter's signed
/// release metadata plus the actual bytes it attests to. Supplied by the
/// self-fetch / out-of-band layer (the network pull is M4).
#[derive(Debug, Clone)]
pub struct FilePullPayload {
    /// The adapter's signed release metadata (signed with the adapter's key).
    pub signed: SignedRelease,
    /// The pulled artifact bytes (hashed + matched against the signed digest).
    pub bytes: Vec<u8>,
}

/// Why an adapter's ripple-update was not applied as a verified content update.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkipReason {
    /// The adapter declares no `[update]` avenue — nothing to conduct.
    NoAvenue,
    /// A `delegated` adapter did not attest `self_verifies`, so its content is
    /// unverifiable by spt-core — skipped rather than trusted blindly.
    UnverifiableDelegated,
    /// A `delegated` adapter attested `self_verifies` but declared no `command`.
    DelegatedMissingCommand,
    /// A `file_pull` adapter is missing its `signing_key` (should be caught at
    /// manifest validation, but the conductor never trusts an unverifiable pull).
    FilePullMissingKey,
    /// A `file_pull` adapter had no payload to verify yet (the pull has not run).
    NoPayload,
}

/// The conductor's verdict for one adapter (REQ-UPD-5).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdapterUpdateOutcome {
    /// `file_pull`: the payload is authentic (adapter key) + intact (digest) and
    /// ready to apply. Carries the verified metadata.
    Verified(ReleaseMetadata),
    /// `delegated` + attested: run the adapter's own updater (the trusted command).
    Delegate(String),
    /// `file_pull`: the payload failed verification — never applied.
    Rejected(RejectReason),
    /// The adapter was skipped (see [`SkipReason`]) — no update applied.
    Skipped(SkipReason),
}

/// Decide one adapter's ripple-update from its `[update]` directive + (for
/// `file_pull`) the pulled payload (REQ-UPD-5). Pure: no fetch, no exec, no clock
/// — the caller supplies the payload and acts on the verdict.
// [impl->REQ-UPD-5]
// [impl->REQ-SEAM-UPDATE]
pub fn plan_adapter_update(
    update: &Update,
    payload: Option<&FilePullPayload>,
) -> AdapterUpdateOutcome {
    match update.avenue {
        UpdateAvenue::FilePull => {
            let Some(key_hex) = update.signing_key.as_deref() else {
                return AdapterUpdateOutcome::Skipped(SkipReason::FilePullMissingKey);
            };
            let key = match parse_verifying_key(key_hex) {
                Ok(k) => k,
                Err(e) => return AdapterUpdateOutcome::Rejected(e),
            };
            let Some(payload) = payload else {
                return AdapterUpdateOutcome::Skipped(SkipReason::NoPayload);
            };
            // Authentic (adapter key) + intact (digest) — the two halves of
            // adapter content signing.
            match verify_signature(&payload.signed, &key) {
                Ok(meta) => match verify_artifact(&meta, &payload.bytes) {
                    Ok(()) => AdapterUpdateOutcome::Verified(meta),
                    Err(e) => AdapterUpdateOutcome::Rejected(e),
                },
                Err(e) => AdapterUpdateOutcome::Rejected(e),
            }
        }
        UpdateAvenue::Delegated => {
            if !update.self_verifies {
                return AdapterUpdateOutcome::Skipped(SkipReason::UnverifiableDelegated);
            }
            match update.command.as_deref() {
                Some(cmd) => AdapterUpdateOutcome::Delegate(cmd.to_string()),
                None => AdapterUpdateOutcome::Skipped(SkipReason::DelegatedMissingCommand),
            }
        }
    }
}

/// An adapter to ripple: its id, its `[update]` directive, and (for `file_pull`)
/// its pulled payload.
pub struct AdapterRipple<'a> {
    pub id: String,
    pub update: &'a Update,
    pub payload: Option<FilePullPayload>,
}

/// Conduct the adapter ripple in **registration order** (REQ-UPD-5). spt-core's
/// own update runs *before* this (the C0–C2 self-update) — this is the
/// adapters-after-self half; the ordering is the caller's contract, preserved
/// here by mapping in order. Returns each adapter's id + verdict.
// [impl->REQ-UPD-5]
pub fn conduct_ripple(adapters: &[AdapterRipple<'_>]) -> Vec<(String, AdapterUpdateOutcome)> {
    adapters
        .iter()
        .map(|a| (a.id.clone(), plan_adapter_update(a.update, a.payload.as_ref())))
        .collect()
}


PowerShell

Get-Content crates\spt-runtime\Cargo.toml; Get-Content crates\spt-runtime\src\lib.rs | Select-String "^pub mod|^mod"

Check spt-runtime deps and modules


[package]
name = "spt-runtime"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core harness contract: runtime-manifest schema + AgentRuntime/ManifestRuntime (opaque-template substitution + spawn-session seam). The outbound half of the harness-agnostic contract (M2a). Public SDK (R-ARCH-2)."

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
serde = { version = "1", features = ["derive"] }
toml = "0.8"

[dev-dependencies]
tempfile = "3"

pub mod manifest;
pub mod runtime;


spt-runtime deps spt-store ✓. Write registry module:


Write

//! Adapter registration lifecycle (M5-D2, REQ-INSTALL-4) — the node-local
//! registered-adapter set under `{SPT_HOME}/adapters/`.
//!
//! `spt adapter add` validates the manifest **first** (nothing is recorded for
//! an invalid one), then records it (PRD R-INSTALL-4): a **copy** of the
//! manifest for `file_pull` / avenue-less adapters (spt-core owns the bytes),
//! a **pointer** back to the source tree for `delegated` ones (the adapter's
//! own updater owns its install; we re-read its manifest live so a delegated
//! self-update never leaves a stale copy behind). One command and one dir for
//! both `kind = "harness"` and `kind = "shell"`.
//!
//! Registration is **node-local capability** (may this node drive/launch the
//! adapter), not subnet advertisement — discovery rides the registry/broadcast
//! gates elsewhere. The registered set is the single source consumed by:
//! creation-time adapter selection (auto-if-one/ask-if-many over
//! `hostable_types`), shell discovery (D3d), and the R-UPD-5 self-update
//! ripple.
//!
//! `spt adapter remove` is a **soft-deregister**: the record stays with
//! `active = false`, so new creation no longer offers the adapter while
//! existing/live instances keep running off it. The manifest's optional
//! `[update] uninstall` template is *returned* to the caller to conduct once
//! the adapter is quiesced — this module records state, it never execs.
//! Re-adding a deregistered name re-activates it (refreshing the record).
//!
//! Layout per adapter: `adapters/<name>/record.toml` (+ `manifest.toml` in
//! copy mode). Records write atomically (REQ-HAZARD-EBUSY-RENAME stance via
//! `spt_store::atomic`).
// [impl->REQ-INSTALL-4]

use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

use spt_store::atomic::atomic_write_string;

use crate::manifest::{AdapterKind, Manifest, UpdateAvenue};

/// How the manifest is held: a copy under `adapters/<name>/`, or a pointer
/// re-read from the source tree per load.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecordMode {
    Copy,
    Pointer,
}

/// One registered adapter's durable record (`adapters/<name>/record.toml`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdapterRecord {
    pub name: String,
    pub kind: AdapterKind,
    pub mode: RecordMode,
    /// Where the adapter was registered from (the manifest's directory) —
    /// the live manifest location in pointer mode, provenance in copy mode.
    pub source_dir: String,
    pub registered_at_ms: u64,
    /// `false` = soft-deregistered: hidden from new-creation/discovery;
    /// existing instances keep running (REQ-INSTALL-4).
    pub active: bool,
}

/// Registration failures — adapter-author-facing, so they carry the why.
#[derive(Debug)]
pub enum RegistryError {
    /// The source manifest failed to read.
    Io(std::io::Error),
    /// The source manifest failed parse/validation — nothing was recorded.
    Manifest(crate::manifest::ManifestError),
    /// `remove`/lookup named an adapter that has no record.
    NotRegistered(String),
    /// A record on disk is unreadable/corrupt (named so the operator can fix).
    BadRecord(String),
}

impl std::fmt::Display for RegistryError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RegistryError::Io(e) => write!(f, "io: {e}"),
            RegistryError::Manifest(e) => write!(f, "manifest invalid: {e}"),
            RegistryError::NotRegistered(n) => write!(f, "adapter not registered: {n}"),
            RegistryError::BadRecord(n) => write!(f, "corrupt adapter record: {n}"),
        }
    }
}

impl From<std::io::Error> for RegistryError {
    fn from(e: std::io::Error) -> Self {
        RegistryError::Io(e)
    }
}

fn record_file(adapters_dir: &Path, name: &str) -> PathBuf {
    adapters_dir.join(name).join("record.toml")
}

fn manifest_copy_file(adapters_dir: &Path, name: &str) -> PathBuf {
    adapters_dir.join(name).join("manifest.toml")
}

/// Resolve the manifest file inside a source: `<dir>/manifest.toml`, or the
/// path itself when it already names a file.
fn source_manifest_file(source: &Path) -> PathBuf {
    if source.is_dir() {
        source.join("manifest.toml")
    } else {
        source.to_path_buf()
    }
}

/// Register (or re-register) the adapter whose manifest lives at `source`
/// (a directory containing `manifest.toml`, or the manifest path itself).
/// Validate-first: an invalid manifest records **nothing**. Mode falls out of
/// the `[update]` avenue — `delegated` ⇒ pointer, `file_pull`/absent ⇒ copy.
/// Re-adding an existing name refreshes the record and re-activates a
/// soft-deregistered one. Returns the record; the caller conducts
/// install-is-first-update through the manifest's `[update]` avenue.
// [impl->REQ-INSTALL-4]
pub fn register(
    adapters_dir: &Path,
    source: &Path,
    now_ms: u64,
) -> Result<(AdapterRecord, Manifest), RegistryError> {
    let manifest_file = source_manifest_file(source);
    let toml_src = std::fs::read_to_string(&manifest_file)?;
    let manifest = Manifest::from_toml_str(&toml_src).map_err(RegistryError::Manifest)?;

    let mode = match manifest.update.as_ref().map(|u| u.avenue) {
        Some(UpdateAvenue::Delegated) => RecordMode::Pointer,
        Some(UpdateAvenue::FilePull) | None => RecordMode::Copy,
    };
    let source_dir = manifest_file
        .parent()
        .unwrap_or(Path::new("."))
        .to_string_lossy()
        .into_owned();

    let record = AdapterRecord {
        name: manifest.adapter.name.clone(),
        kind: manifest.adapter.kind,
        mode,
        source_dir,
        registered_at_ms: now_ms,
        active: true,
    };

    let dir = adapters_dir.join(&record.name);
    std::fs::create_dir_all(&dir)?;
    if mode == RecordMode::Copy {
        atomic_write_string(&manifest_copy_file(adapters_dir, &record.name), &toml_src)?;
    }
    save_record(adapters_dir, &record)?;
    Ok((record, manifest))
}

/// Soft-deregister `name`: flip `active = false` (the record + any manifest
/// copy stay — existing instances keep running off them). Returns the
/// manifest's optional `[update] uninstall` template for the caller to
/// conduct once the adapter is quiesced (`spt adapter remove`'s optional
/// second half); `None` = spt-core default cleanup. Idempotent on an
/// already-inactive record.
// [impl->REQ-INSTALL-4]
pub fn deregister(adapters_dir: &Path, name: &str) -> Result<Option<String>, RegistryError> {
    let mut record = load_record(adapters_dir, name)?;
    record.active = false;
    save_record(adapters_dir, &record)?;
    let uninstall = load_manifest(adapters_dir, &record)
        .ok()
        .and_then(|m| m.update.and_then(|u| u.uninstall));
    Ok(uninstall)
}

/// One adapter's record by name.
pub fn load_record(adapters_dir: &Path, name: &str) -> Result<AdapterRecord, RegistryError> {
    let path = record_file(adapters_dir, name);
    if !path.exists() {
        return Err(RegistryError::NotRegistered(name.to_string()));
    }
    let s = std::fs::read_to_string(&path)?;
    toml::from_str(&s).map_err(|_| RegistryError::BadRecord(name.to_string()))
}

fn save_record(adapters_dir: &Path, record: &AdapterRecord) -> Result<(), RegistryError> {
    let s = toml::to_string_pretty(record)
        .map_err(|e| RegistryError::BadRecord(format!("{}: {e}", record.name)))?;
    atomic_write_string(&record_file(adapters_dir, &record.name), &s)?;
    Ok(())
}

/// The record's manifest: the held copy in copy mode, the live source tree in
/// pointer mode (a delegated adapter's self-updates are always honored).
// [impl->REQ-INSTALL-4]
pub fn load_manifest(
    adapters_dir: &Path,
    record: &AdapterRecord,
) -> Result<Manifest, RegistryError> {
    let path = match record.mode {
        RecordMode::Copy => manifest_copy_file(adapters_dir, &record.name),
        RecordMode::Pointer => source_manifest_file(Path::new(&record.source_dir)),
    };
    let s = std::fs::read_to_string(&path)?;
    Manifest::from_toml_str(&s).map_err(RegistryError::Manifest)
}

/// Every record under `adapters/` (active and not — callers filter), sorted
/// by name for a stable order. Tolerant: an unreadable entry is skipped, the
/// rest still list (the stale-registry degrade stance).
pub fn all_records(adapters_dir: &Path) -> Vec<AdapterRecord> {
    let Ok(entries) = std::fs::read_dir(adapters_dir) else {
        return Vec::new();
    };
    let mut out: Vec<AdapterRecord> = entries
        .filter_map(|e| e.ok())
        .filter_map(|e| load_record(adapters_dir, &e.file_name().to_string_lossy()).ok())
        .collect();
    out.sort_by(|a, b| a.name.cmp(&b.name));
    out
}

/// The **active** registered set with manifests resolved — the single source
/// for creation-time adapter selection, shell discovery, and the R-UPD-5
/// ripple. A pointer-mode record whose source tree has vanished is skipped
/// (tolerant), not fatal.
// [impl->REQ-INSTALL-4]
pub fn registered(adapters_dir: &Path) -> Vec<(AdapterRecord, Manifest)> {
    all_records(adapters_dir)
        .into_iter()
        .filter(|r| r.active)
        .filter_map(|r| load_manifest(adapters_dir, &r).ok().map(|m| (r, m)))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    const HARNESS_COPY: &str = r#"
[adapter]
name = "mock-harness"
kind = "harness"
version = "1.0.0"
min_spt_core_version = "0.0.1"
hostable_types = ["LiveAgent"]
"#;

    const SHELL_DELEGATED: &str = r#"
[adapter]
name = "mock-shell"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "0.0.1"

[update]
avenue = "delegated"
command = "mock-shell-updater"
self_verifies = true
uninstall = "mock-shell-uninstaller --purge"

[shell]
spawn = "mock-shell --link {link_token}"
"#;

    fn seed_source(dir: &Path, name: &str, toml_src: &str) -> PathBuf {
        let src = dir.join(name);
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(src.join("manifest.toml"), toml_src).unwrap();
        src
    }

    // [unit->REQ-INSTALL-4] register validates-first and records by avenue:
    // an avenue-less harness is copied; a delegated shell is a pointer whose
    // manifest reads live from the source tree. One dir serves both kinds.
    #[test]
    fn register_copy_vs_pointer_by_avenue() {
        let tmp = tempfile::tempdir().unwrap();
        let adapters = tmp.path().join("adapters");
        let harness_src = seed_source(tmp.path(), "h-src", HARNESS_COPY);
        let shell_src = seed_source(tmp.path(), "s-src", SHELL_DELEGATED);

        let (h, hm) = register(&adapters, &harness_src, 1000).unwrap();
        assert_eq!((h.mode, h.kind), (RecordMode::Copy, AdapterKind::Harness));
        assert_eq!(hm.adapter.name, "mock-harness");
        assert!(adapters.join("mock-harness").join("manifest.toml").exists(), "copy held");

        let (s, _) = register(&adapters, &shell_src, 2000).unwrap();
        assert_eq!((s.mode, s.kind), (RecordMode::Pointer, AdapterKind::Shell));
        assert!(!adapters.join("mock-shell").join("manifest.toml").exists(), "no copy");
        // Pointer reads live: mutate the source, the loaded manifest follows.
        std::fs::write(
            shell_src.join("manifest.toml"),
            SHELL_DELEGATED.replace("1.0.0", "1.1.0"),
        )
        .unwrap();
        let live = load_manifest(&adapters, &s).unwrap();
        assert_eq!(live.adapter.version, "1.1.0");

        let set = registered(&adapters);
        assert_eq!(
            set.iter().map(|(r, _)| r.name.as_str()).collect::<Vec<_>>(),
            vec!["mock-harness", "mock-shell"],
            "stable name order"
        );
    }

    // [unit->REQ-INSTALL-4] an invalid manifest records nothing — the
    // validate-first rule (manifest-first, then install).
    #[test]
    fn invalid_manifest_records_nothing() {
        let tmp = tempfile::tempdir().unwrap();
        let adapters = tmp.path().join("adapters");
        // kind = "shell" without a [shell] body fails validation.
        let bad = seed_source(
            tmp.path(),
            "bad-src",
            "[adapter]\nname = \"bad\"\nkind = \"shell\"\nversion = \"1\"\nmin_spt_core_version = \"0\"\n",
        );
        assert!(matches!(register(&adapters, &bad, 0), Err(RegistryError::Manifest(_))));
        assert!(!adapters.join("bad").exists(), "nothing recorded");
        assert!(registered(&adapters).is_empty());
    }

    // [unit->REQ-INSTALL-4] remove is a soft-deregister: hidden from the
    // active set, record + copy retained (live instances keep running), the
    // manifest's uninstall template surfaced to the caller; re-add
    // re-activates.
    #[test]
    fn deregister_is_soft_and_readd_reactivates() {
        let tmp = tempfile::tempdir().unwrap();
        let adapters = tmp.path().join("adapters");
        let shell_src = seed_source(tmp.path(), "s-src", SHELL_DELEGATED);
        register(&adapters, &shell_src, 1000).unwrap();

        let uninstall = deregister(&adapters, "mock-shell").unwrap();
        assert_eq!(uninstall.as_deref(), Some("mock-shell-uninstaller --purge"));
        assert!(registered(&adapters).is_empty(), "hidden from the active set");
        let rec = load_record(&adapters, "mock-shell").unwrap();
        assert!(!rec.active, "record retained, inactive");

        // Unknown name errs; double-deregister is idempotent.
        assert!(matches!(
            deregister(&adapters, "ghost"),
            Err(RegistryError::NotRegistered(_))
        ));
        assert!(deregister(&adapters, "mock-shell").is_ok());

        let (re, _) = register(&adapters, &shell_src, 2000).unwrap();
        assert!(re.active && re.registered_at_ms == 2000, "re-add re-activates + refreshes");
        assert_eq!(registered(&adapters).len(), 1);
    }
}