4440	            }
4441	        }
4442	        // [impl->REQ-MANIFEST-3]
4443	        AdapterCmd::GetString { option, key } => match registry::get_string(&adapters, &option, &key)
4444	        {
4445	            Ok(Some(value)) => {
4446	                // Strings print raw; structured leaves print as JSON (machine-
4447	                // readable for a hook). spt-core never executes the value.
4448	                if let Some(s) = value.as_str() {
4449	                    println!("{s}");
4450	                } else {
4451	                    println!("{}", serde_json::to_string(&value).unwrap_or_default());
4452	                }
4453	                0
4454	            }
4455	            Ok(None) => {
4456	                eprintln!("ADAPTER_STRING_UNSET:{option}: no value at '{key}'");
4457	                1
4458	            }
4459	            Err(e) => {
4460	                eprintln!("ADAPTER_STRING_FAIL:{option}: {e}");
4461	                1
4462	            }
4463	        },
4464	        // [impl->REQ-MANIFEST-3]
4465	        AdapterCmd::SetString { option, key, value } => {
4466	            let (adapter, profile) = spt_runtime::profile::split_option(&option);
4467	            let Some(profile) = profile else {
4468	                eprintln!(
4469	                    "ADAPTER_STRING_NO_TARGET:{option}: set-string needs a <adapter>:<profile> \
4470	                     local target (create-profile first)"
4471	                );
4472	                return 2;
4473	            };
4474	            match registry::set_local_string(&adapters, adapter, profile, &key, &value) {
4475	                Ok(()) => {
4476	                    eprintln!("ADAPTER_STRING_SET:{option}:{key}");
4477	                    0
4478	                }
4479	                Err(e) => {
4480	                    eprintln!("ADAPTER_STRING_SET_FAIL:{option}:{key}: {e}");
4481	                    1
4482	                }
4483	            }
4484	        }
4485	    }
4486	}
4487
4488	/// `spt shell spawn|list|teardown|rename|cmd|relink` — the shell-instance
4489	/// surface (M5-D3a, REQ-EP-5). Spawn **mints** identity (`<adapter>-<n>` +
4490	/// perch + record, offline); the broker launch + `api bind` handshake
4491	/// onlines it (D3b); `cmd`/`send` are the channels (D3c); `relink` is the
4492	/// manual online switch and `teardown` runs the graceful close first
4493	/// (M5-D4a). Owner-scoped throughout: refs only resolve within the owner's
4494	/// own `shells/` namespace.
4495	// [impl->REQ-EP-5]
4496	fn cmd_shell(action: ShellCmd) -> i32 {
4497	    use spt_store::shellinfo;
4498
4499	    /// Resolve the acting owner: explicit `--owner`, else the session's own

            }
        }
        // [impl->REQ-MANIFEST-3]
        AdapterCmd::GetString { option, key } => match registry::get_string(&adapters, &option, &key)
        {
            Ok(Some(value)) => {
                // Strings print raw; structured leaves print as JSON (machine-
                // readable for a hook). spt-core never executes the value.
                if let Some(s) = value.as_str() {
                    println!("{s}");
                } else {
                    println!("{}", serde_json::to_string(&value).unwrap_or_default());
                }
                0
            }
            Ok(None) => {
                eprintln!("ADAPTER_STRING_UNSET:{option}: no value at '{key}'");
                1
            }
            Err(e) => {
                eprintln!("ADAPTER_STRING_FAIL:{option}: {e}");
                1
            }
        },
        // [impl->REQ-MANIFEST-3]
        AdapterCmd::SetString { option, key, value } => {
            let (adapter, profile) = spt_runtime::profile::split_option(&option);
            let Some(profile) = profile else {
                eprintln!(
                    "ADAPTER_STRING_NO_TARGET:{option}: set-string needs a <adapter>:<profile> \
                     local target (create-profile first)"
                );
                return 2;
            };
            match registry::set_local_string(&adapters, adapter, profile, &key, &value) {
                Ok(()) => {
                    eprintln!("ADAPTER_STRING_SET:{option}:{key}");
                    0
                }
                Err(e) => {
                    eprintln!("ADAPTER_STRING_SET_FAIL:{option}:{key}: {e}");
                    1
                }
            }
        }
    }
}

/// `spt shell spawn|list|teardown|rename|cmd|relink` — the shell-instance
/// surface (M5-D3a, REQ-EP-5). Spawn **mints** identity (`<adapter>-<n>` +
/// perch + record, offline); the broker launch + `api bind` handshake
/// onlines it (D3b); `cmd`/`send` are the channels (D3c); `relink` is the
/// manual online switch and `teardown` runs the graceful close first
/// (M5-D4a). Owner-scoped throughout: refs only resolve within the owner's
/// own `shells/` namespace.
// [impl->REQ-EP-5]
fn cmd_shell(action: ShellCmd) -> i32 {
    use spt_store::shellinfo;

    /// Resolve the acting owner: explicit `--owner`, else the session's own


4380	                // the parent manifest) then local (overlay files beside it).
4381	                // [impl->REQ-MANIFEST-2]
4382	                if let Ok(m) = registry::load_manifest(&adapters, &r) {
4383	                    let mut shipped: Vec<&String> = m.profiles.keys().collect();
4384	                    shipped.sort();
4385	                    for p in shipped {
4386	                        println!("  {}:{p} (shipped)", r.name);
4387	                    }
4388	                }
4389	                for p in registry::local_profile_names(&adapters, &r.name) {
4390	                    println!("  {}:{p} (local)", r.name);
4391	                }
4392	            }
4393	            0
4394	        }
4395	        // [impl->REQ-MANIFEST-2]
4396	        AdapterCmd::CreateProfile {
4397	            adapter,
4398	            name,
4399	            from,
4400	        } => {
4401	            let overlay_toml = match from {
4402	                Some(f) => match std::fs::read_to_string(&f) {
4403	                    Ok(s) => s,
4404	                    Err(e) => {
4405	                        eprintln!("ADAPTER_PROFILE_SOURCE:{f}: {e}");
4406	                        return 1;
4407	                    }
4408	                },
4409	                None => {
4410	                    use std::io::IsTerminal;
4411	                    if std::io::stdin().is_terminal() {
4412	                        String::new() // a placeholder profile; populate via set-string
4413	                    } else {
4414	                        std::io::read_to_string(std::io::stdin()).unwrap_or_default()
4415	                    }
4416	                }
4417	            };
4418	            match registry::create_local_profile(&adapters, &adapter, &name, &overlay_toml) {
4419	                Ok(()) => {
4420	                    eprintln!("ADAPTER_PROFILE_CREATED:{adapter}:{name} (local)");
4421	                    0
4422	                }
4423	                Err(e) => {
4424	                    eprintln!("ADAPTER_PROFILE_CREATE_FAIL:{adapter}:{name}: {e}");
4425	                    1
4426	                }
4427	            }
4428	        }
4429	        // [impl->REQ-MANIFEST-2]
4430	        AdapterCmd::DeleteProfile { adapter, name } => {
4431	            match registry::delete_local_profile(&adapters, &adapter, &name) {
4432	                Ok(()) => {
4433	                    eprintln!("ADAPTER_PROFILE_DELETED:{adapter}:{name}");
4434	                    0
4435	                }
4436	                Err(e) => {
4437	                    eprintln!("ADAPTER_PROFILE_DELETE_FAIL:{adapter}:{name}: {e}");
4438	                    1
4439	                }
4440	            }
4441	        }

                // the parent manifest) then local (overlay files beside it).
                // [impl->REQ-MANIFEST-2]
                if let Ok(m) = registry::load_manifest(&adapters, &r) {
                    let mut shipped: Vec<&String> = m.profiles.keys().collect();
                    shipped.sort();
                    for p in shipped {
                        println!("  {}:{p} (shipped)", r.name);
                    }
                }
                for p in registry::local_profile_names(&adapters, &r.name) {
                    println!("  {}:{p} (local)", r.name);
                }
            }
            0
        }
        // [impl->REQ-MANIFEST-2]
        AdapterCmd::CreateProfile {
            adapter,
            name,
            from,
        } => {
            let overlay_toml = match from {
                Some(f) => match std::fs::read_to_string(&f) {
                    Ok(s) => s,
                    Err(e) => {
                        eprintln!("ADAPTER_PROFILE_SOURCE:{f}: {e}");
                        return 1;
                    }
                },
                None => {
                    use std::io::IsTerminal;
                    if std::io::stdin().is_terminal() {
                        String::new() // a placeholder profile; populate via set-string
                    } else {
                        std::io::read_to_string(std::io::stdin()).unwrap_or_default()
                    }
                }
            };
            match registry::create_local_profile(&adapters, &adapter, &name, &overlay_toml) {
                Ok(()) => {
                    eprintln!("ADAPTER_PROFILE_CREATED:{adapter}:{name} (local)");
                    0
                }
                Err(e) => {
                    eprintln!("ADAPTER_PROFILE_CREATE_FAIL:{adapter}:{name}: {e}");
                    1
                }
            }
        }
        // [impl->REQ-MANIFEST-2]
        AdapterCmd::DeleteProfile { adapter, name } => {
            match registry::delete_local_profile(&adapters, &adapter, &name) {
                Ok(()) => {
                    eprintln!("ADAPTER_PROFILE_DELETED:{adapter}:{name}");
                    0
                }
                Err(e) => {
                    eprintln!("ADAPTER_PROFILE_DELETE_FAIL:{adapter}:{name}: {e}");
                    1
                }
            }
        }


<task-notification>
<task-id>br2rnyjsd</task-id>
<tool-use-id>toolu_01MgLFnY6kAAr7QKN2Khx8Yq</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\13c96a50-2b78-4c16-a995-1da2b1836fd8\tasks\br2rnyjsd.output</output-file>
<status>completed</status>
<summary>Background command "Run traceable-reqs check" completed (exit code 0)</summary>
</task-notification>


CLI shape complete: `list` renders shipped+local profiles adjacent to parent, `create-profile` takes `--from <file>` or stdin (empty placeholder on a TTY → populate via set-string), `delete-profile`/`set-string` local-only via registry guards, `get-string` prints raw strings / JSON for structured leaves. Checking verification results.


1	usage: xtask <gen|check|site <outdir>|release-keygen <key-id>|release-sign …|release-publish …|debug-keygen <key-id>|debug-pin …|debug-rollout …|debug-converge --version <u64> …>
2	EXIT:0
3

usage: xtask <gen|check|site <outdir>|release-keygen <key-id>|release-sign …|release-publish …|debug-keygen <key-id>|debug-pin …|debug-rollout …|debug-converge --version <u64> …>
EXIT:0


Prompt:
<task-notification>
<task-id>br2rnyjsd</task-id>
<tool-use-id>toolu_01MgLFnY6kAAr7QKN2Khx8Yq</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\13c96a50-2b78-4c16-a995-1da2b1836fd8\tasks\br2rnyjsd.output</output-file>
<status>completed</status>
<summary>Background command "Run traceable-reqs check" completed (exit code 0)</summary>
</task-notification>


Wrong subcommand form — xtask takes `check` directly, not `traceable check`. Re-running.


Bash

cd C:/Users/decid/Documents/projects/spt-core && cargo run -q -p xtask -- check 2>&1 | tail -25; echo "EXIT:${PIPESTATUS[0]}"

Run xtask check (traceable)


Command running in background with ID: bmqv6b10d. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\13c96a50-2b78-4c16-a995-1da2b1836fd8\tasks\bmqv6b10d.output. You will be notified when it completes. To check interim output, use Read on that file path.


<system-reminder>Warning: the file exists but is shorter than the provided offset (1). The file has 1 lines.</system-reminder>


Tests still running. While they finish — re-examined the one decision todlando flagged (skip-seen → next-unseen-match for hints). It's consistent with both CONTEXT invariants (≤1/message, once/session each) and strictly more useful than first-match-only, but CONTEXT doesn't pin the tiebreak. That's a doc-gap to close, not a deviation. Waiting on test results.


brief pause


waiting


<task-notification>
<task-id>b7pcps5gp</task-id>
<tool-use-id>toolu_0126KvkqxxcJTo7hZocQqeDX</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\13c96a50-2b78-4c16-a995-1da2b1836fd8\tasks\b7pcps5gp.output</output-file>
<status>completed</status>
<summary>Background command "Run spt-runtime + spt-store tests" completed (exit code 0)</summary>
</task-notification>


I'll wait for the two background tasks (xtask check + crate tests) to complete rather than poll.


1
2	running 44 tests
3	............................................
4	test result: ok. 44 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
5
6
7	running 152 tests
8	....................................................................................... 87/152
9	.................................................................
10	test result: ok. 152 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.77s
11
12
13	running 0 tests
14
15	test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
16
17
18	running 0 tests
19
20	test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
21
22	EXIT:0
23

running 44 tests
............................................
test result: ok. 44 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s

running 152 tests
....................................................................................... 87/152
.................................................................
test result: ok. 152 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.77s

running 0 tests

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

running 0 tests

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

EXIT:0


Prompt:
<task-notification>
<task-id>b7pcps5gp</task-id>
<tool-use-id>toolu_0126KvkqxxcJTo7hZocQqeDX</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\13c96a50-2b78-4c16-a995-1da2b1836fd8\tasks\b7pcps5gp.output</output-file>
<status>completed</status>
<summary>Background command "Run spt-runtime + spt-store tests" completed (exit code 0)</summary>
</task-notification>


Tests GREEN: spt-runtime 44 + spt-store 152, 0 fail. Waiting on the traceable check.


I'll wait for the traceable check to finish (it recompiles xtask, so it takes a moment).


Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.


<task-notification>
<task-id>bmqv6b10d</task-id>
<tool-use-id>toolu_01NaYQHB1m4eMuwazkJQb1mP</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\13c96a50-2b78-4c16-a995-1da2b1836fd8\tasks\bmqv6b10d.output</output-file>
<status>completed</status>
<summary>Background command "Run xtask check (traceable)" completed (exit code 0)</summary>
</task-notification>


Polling wastes calls — I'll be notified when xtask check completes. Meanwhile, confirming the `api hint` command wiring (dispatch + manifest plumbing) to close the last hint-feature thread.


198:    /// Keyword hints: the full user message arrives on **stdin**; emit at most
199:    /// one matched hint line (declaration order, first unseen wins) for the
200:    /// adapter's context channel. The per-session seen-set fires each hint once
202:    Hint {
285:        ApiCmd::Hint { session } => reporting::cmd_hint(&ctx, &session),


Prompt:
<task-notification>
<task-id>bmqv6b10d</task-id>
<tool-use-id>toolu_01NaYQHB1m4eMuwazkJQb1mP</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\13c96a50-2b78-4c16-a995-1da2b1836fd8\tasks\bmqv6b10d.output</output-file>
<status>completed</status>
<summary>Background command "Run xtask check (traceable)" completed (exit code 0)</summary>
</task-notification>


1	xtask check: OK
2	EXIT:0
3

xtask check: OK
EXIT:0


All verification GREEN independently. G1 review complete. Sending verdict to todlando.


$OWL send todlando <<'EOF'
═══ G1 VERDICT: PASS ═══ (PR #7, M9 Wave 1 — adapter customization)

doyle reviewed the whole survey list + engine + every consumer seam + independently re-ran verification. Wave 1 clears the gate. One non-blocking doc-pin condition below.

WHAT I VERIFIED
• Leaf-replace engine (profile.rs): unit table covers the full CONTEXT contract — scalar replace, nested sibling-preserve, array-wholesale (never spliced), overlay-only-add, base-survive, empty no-op, BOTH type-change directions. Clean.
• Three resolvers share ONE apply_option_overlay core (disk / preloaded-slice / caller-parent) — no drift risk. resolve_option honors the active flag (soft-deregistered refuses new resolution; existing instances ride on). Confirmed.
• Survey #4/#6 comparison risk RESOLVED: api load_manifest (415-416) and cli filter (4658) both split → compare PARENT segment, never the whole composite. Verified by read, not just claim.
• Canon-id hazard #8 CLOSED — and better than the survey's "split on last -" plan: instance_ordinal anchors on the known parent prefix THEN requires all-ASCII-digit suffix, so no false collision even when one adapter name is a prefix of another (claude vs claude-spt vs an adapter literally named claude-2 all disjoint). REQ-HAZARD-ID-CHARSET + unit. Composite rides only adapter_name carrier; id stays colon-free. Solid.
• Daemon seams #1/#2/#3 (shellhost/shellwake shell_section_of, linkhost resolve_link_target) all resolve the merged view + return OWNED Shell. No borrow-into-parent.
• Registration guards: shadow-refusal enforced create+delete+set-string (both directions); validate_profile_name forbids :/path-sep/whitespace/dot-seg; tighten-only via resolve_overlay (require_approval AND over_cap ranked). create=nothing-written-on-any-failure. Local survives re-add (separate test). All present.
• Strings: data-only, get-string overlay-resolved (raw print / JSON for structured leaves), set-string composite-target-required (bare → exit 2 clear msg), shipped immutable. Good.
• Hints: ≤1/message (early return), once/session (content-keyed seen-set in session_dir, /clear=new session re-arms), literal-substring default + regex opt-in, invalid regex silences its own hint (no panic). matched_keyword + select_and_mark_hint testable-core split. Good.

INDEPENDENT VERIFICATION (re-ran, not trusting the summary)
• xtask check: OK (exit 0)
• cargo test spt-runtime: 44 passed / 0 fail
• cargo test spt-store: 152 passed / 0 fail

═══ CONDITION (non-blocking — fold into this wave before merge) ═══
The skip-seen→next-unseen-match tiebreak you flagged: I ACCEPT it over strict first-match-only — it's strictly more useful and violates NEITHER CONTEXT invariant. BUT CONTEXT §keyword hints doesn't pin the tiebreak, so right now it's an implementation accident a future refactor could "fix" backward. Pin it: add one clause to CONTEXT §keyword hints — "if the declaration-order-first matching hint is already seen this session, the scan falls through to the next unseen matching hint (≤1/message still holds)". Same PR, doc-only. That keeps CONTEXT authoritative.

MINOR (cosmetic, your call — no action required)
• REQ-MANIFEST-2 row has no activation-date comment; -3/-4 do. Tidy if you like.
• profile.rs split_option doc says "a profile name may contain [a colon]" — true only for the shipped path (first-colon split); local names forbid ':'. Comment scope is slightly loose, not wrong.

Gate cleared. Proceed to Wave 2 (user-msg → Gateway acceptance → live-role.md) — that's G2, I'm armed for it. Land the CONTEXT doc-pin into Wave 1's PR and you're done here.
EOF

Send G1 verdict to todlando


➜ SENT:todlando


G1 verdict delivered. Syncing psyche so resume state reflects G1 PASS → next is G2.


Write

# Commune — G1 PASSED (M9 Wave 1)

## Status delta
Gen 19. G1 gate on PR #7 (m9-adapter-custom) = **PASS**, delivered to todlando.

## What cleared
M9 Wave 1 (adapter customization: profiles/strings/hints) reviewed whole — survey list + engine + every consumer seam. Verdict PASS.
- Leaf-replace engine: full CONTEXT contract covered by unit table.
- 3 resolvers share one apply_option_overlay core; soft-deregister honored.
- Survey #4/#6 (parent-segment compare) RESOLVED; canon-id hazard #8 CLOSED (REQ-HAZARD-ID-CHARSET — instance_ordinal anchors parent-prefix + all-digit guard, robust vs prefix collisions). Daemon seams #1/#2/#3 return owned Shell.
- Registration: shadow-refusal both directions, tighten-only (require_approval + over_cap), name validation, nothing-written-on-fail, local-survives-readd.
- Independently re-ran: xtask check OK; spt-runtime 44 + spt-store 152 tests pass, 0 fail.

## 1 condition (non-blocking, folds into Wave 1 PR)
Hint skip-seen→next-unseen-match tiebreak ACCEPTED over first-match-only — but CONTEXT §keyword hints doesn't pin it. Required todlando add a one-clause CONTEXT doc-pin (same PR, doc-only) so it's ratified, not incidental.

## RESUME HERE (next)
1. Watch for todlando confirming CONTEXT doc-pin landed in Wave 1 PR (then Wave 1 fully done).
2. **G2** (Wave 2 m9-user-surfaces: user-msg → Gateway acceptance → live-role.md). Armed for: user-msg N-1 wire tolerance, ENVELOPE-DECODE-ORDER, identity-gate truth table, role injection order (role→live-context→project), no-automated-writer proof (spt endpoint role = sole live-role.md writer).
3. G3 (Wave 3: digest log-projection + REQ-TERM-4 re-point audit, zero PTY-parse remnants, CC-fixture digest E2E, docs sweep). Watch: history-grain fallback pre-authorized but MUST be flagged at G3 if taken.

CONTEXT.md @ bd469b9 = normative; deviation = STOP-and-ping. No tag/release rides M9.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\.claude\doyle-commune.md (file state is current in your context — no need to Read it back)

# Commune — G1 PASSED (M9 Wave 1)

## Status delta
Gen 19. G1 gate on PR #7 (m9-adapter-custom) = **PASS**, delivered to todlando.

## What cleared
M9 Wave 1 (adapter customization: profiles/strings/hints) reviewed whole — survey list + engine + every consumer seam. Verdict PASS.
- Leaf-replace engine: full CONTEXT contract covered by unit table.
- 3 resolvers share one apply_option_overlay core; soft-deregister honored.
- Survey #4/#6 (parent-segment compare) RESOLVED; canon-id hazard #8 CLOSED (REQ-HAZARD-ID-CHARSET — instance_ordinal anchors parent-prefix + all-digit guard, robust vs prefix collisions). Daemon seams #1/#2/#3 return owned Shell.
- Registration: shadow-refusal both directions, tighten-only (require_approval + over_cap), name validation, nothing-written-on-fail, local-survives-readd.
- Independently re-ran: xtask check OK; spt-runtime 44 + spt-store 152 tests pass, 0 fail.

## 1 condition (non-blocking, folds into Wave 1 PR)
Hint skip-seen→next-unseen-match tiebreak ACCEPTED over first-match-only — but CONTEXT §keyword hints doesn't pin it. Required todlando add a one-clause CONTEXT doc-pin (same PR, doc-only) so it's ratified, not incidental.

## RESUME HERE (next)
1. Watch for todlando confirming CONTEXT doc-pin landed in Wave 1 PR (then Wave 1 fully done).
2. **G2** (Wave 2 m9-user-surfaces: user-msg → Gateway acceptance → live-role.md). Armed for: user-msg N-1 wire tolerance, ENVELOPE-DECODE-ORDER, identity-gate truth table, role injection order (role→live-context→project), no-automated-writer proof (spt endpoint role = sole live-role.md writer).
3. G3 (Wave 3: digest log-projection + REQ-TERM-4 re-point audit, zero PTY-parse remnants, CC-fixture digest E2E, docs sweep). Watch: history-grain fallback pre-authorized but MUST be flagged at G3 if taken.

CONTEXT.md @ bd469b9 = normative; deviation = STOP-and-ping. No tag/release rides M9.


## G1 VERDICT: PASS — M9 Wave 1 (PR #7)

Reviewed the whole Wave 1 (adapter profiles + strings + hints), not just the diff — the survey list, the merge engine, and every one of the 12 consumer seams — then independently re-ran verification.

**Engine + seams — all correct:**
- Leaf-replace merge: unit table covers the full CONTEXT contract (scalar replace, nested sibling-preserve, array-wholesale, overlay-add, base-survive, empty no-op, both type-change directions).
- Three resolvers (disk / preloaded-slice / caller-parent) share one `apply_option_overlay` core — no drift. Soft-deregister honored (refuses new resolution, existing instances ride on).
- **The two flagged risks resolved:** #4/#6 comparison sites split → compare the *parent* segment, never the whole composite. Canon-id hazard #8 closed even more robustly than the survey's plan — `instance_ordinal` anchors on the known parent prefix + requires all-ASCII-digit suffix, so no false collision even when one adapter name is a prefix of another. `REQ-HAZARD-ID-CHARSET` minted with a unit.
- Daemon seams (shellhost/shellwake/linkhost) return owned `Shell` off the merged view. Registration guards: shadow-refusal both directions, tighten-only (`require_approval` + `over_cap`), name validation, nothing-written-on-failure, local-survives-readd.

**Independent verification (re-ran, didn't trust the summary):** xtask check OK · spt-runtime 44 + spt-store 152 tests pass, 0 fail.

**One non-blocking condition:** the hint skip-seen→next-unseen-match tiebreak — I accept it over strict first-match-only (more useful, violates neither CONTEXT invariant), but CONTEXT §keyword hints doesn't pin it. Required todlando add a one-clause doc-pin in the same PR so it's ratified, not an implementation accident. Two cosmetic notes passed along (no action required).

Verdict + psyche synced. Now armed for **G2** (Wave 2: user-msg → Gateway acceptance → live-role.md). Idle until that ping or todlando confirms the doc-pin landed.


<task-notification>
<task-id>bg7cqpvni</task-id>
<summary>Monitor event: "« spt event »"</summary>
<event><EVENT type="msg" from="todlando">G1 PASS received, thanks. My delivery truncated at part 8/12 — I have everything through &quot;CONDITION (non-blocking — fold into this wave before merge&quot; but the condition TEXT itself got cut. Restate the doc-pin condition precisely (what to pin/where) so I fold it in before merge?</EVENT></event>
</task-notification>


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


Todlando's copy truncated before the condition text. Restating it precisely, kept short to survive chunking.


Bash

$OWL send todlando <<'EOF'
G1 doc-pin condition (restated, precise):

WHERE: CONTEXT.md §keyword hints (the para ratified 2026-06-12 — core milestone A; currently ends "...emits at most one hint per message").

WHAT TO ADD (one clause, doc-only, same PR): pin the skip-seen tiebreak —

"When a message matches multiple hints, scan in declaration order and emit the FIRST match whose hint this session has not yet seen; if the declaration-order-first matching hint is already seen, fall through to the next unseen matching hint (the once-per-session and ≤1-per-message invariants both still hold). Emit nothing only when every matching hint is already seen."

WHY: your select_and_mark_hint does exactly this (skip-seen→next-unseen), and I accept it over strict first-match-only — strictly more useful, violates neither invariant. But CONTEXT doesn't currently pin the tiebreak, so it reads as incidental. This clause makes it ratified contract → a future refactor can't silently "fix" it back to first-match-only.

That's the whole condition. Doc-only, fold into Wave 1 PR, then G1 fully closed. No code change.
EOF

Restate doc-pin condition to todlando


Restated. The doc-pin condition: add one clause to **CONTEXT.md §keyword hints** pinning the skip-seen→next-unseen-match tiebreak that `select_and_mark_hint` already implements — so a future refactor can't silently revert it to first-match-only. Doc-only, folds into Wave 1's PR, no code change. G1 fully closes once it lands.

Idle until todlando confirms or Wave 2 (G2) arrives.


<task-notification>
<task-id>bg7cqpvni</task-id>
<summary>Monitor event: "« spt event »"</summary>
<event><EVENT type="msg" from="todlando">ACK G1 PASS + doc-pin condition. Folding the skip-seen tiebreak clause into CONTEXT.md §keyword hints now (doc-only, this PR). Will commit + push; CI already has n1-gate green both runners, test+traceability running. Confirm when pushed + green → G1 fully closed.</EVENT></event>
</task-notification>


Todlando ACK'd — folding the doc-pin into CONTEXT.md §keyword hints now, will push and report green. No action on my side. Waiting for the push+CI-green confirmation to fully close G1, then G2 (Wave 2) is next.


<task-notification>
<task-id>bg7cqpvni</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/2" id="197abd3c" type="msg" from="todlando">G1 FULLY CLOSED. Doc-pin folded (b189513: CONTEXT §keyword hints skip-seen tiebreak as ratified contract). CI GREEN both runners on PR #7 run 27459638509: test Win 7m37s + test Linux 6m13s + n1-gate both + traceability all ✓ (twohost-a/b skipped, no [twohost] tag). Wave 1 (T1 profiles + T2 strings + T3 hints) done</EVENT-PART>
<EVENT-PART seq="2/2" id="197abd3c">, green, gated.

Merge-ready. Holding the actual merge-to-main for operator go (told them I would). Once merged → Wave 2 (m9-user-surfaces: T4 user-msg, T5 Gateway acceptance, T6 live-role.md) → G2. Anything for Wave 2 you want front-loaded?</EVENT-PART></event>
</task-notification>


G1 fully closed — doc-pin at b189513, CI green both runners (PR #7), merge held for operator. Front-loading G2 asks for todlando, then syncing psyche.


$OWL send todlando <<'EOF'
G1 FULLY CLOSED confirmed — doc-pin b189513, PR #7 green both runners, merge-for-operator-go noted. Clean wave.

YES — front-load these G2 artifacts (produce alongside the code, same way the addressing survey grounded G1; lets my gate be fast + evidence-based not claim-based):

1. **live-role.md WRITER SURVEY** (the T6 cross-cutting risk — analog of the G1 addressing survey). Every code path that can write tracked/live-role.md, classified: the spt endpoint `role` surface (the ONE legitimate writer) vs everything else (must be zero automated writers). This IS the no-automated-writer proof — I review the list whole, not just the diff. A missed writer = silent contract break, same failure class as a missed addressing consumer. Make it a tracked artifact (M9-WAVE2-ROLE-WRITERS.md or similar).

2. **user-msg IDENTITY-GATE TRUTH TABLE** (T4) as an explicit artifact, not buried in code: rows = sender identity class × verdict (accept as user authority / refuse), incl. the spoofing-attempt rows. I want to read the gate as a table and match it to the gating code.

3. **ENVELOPE-DECODE-ORDER + N-1 wire tolerance** (T4): pin the decode order explicitly (the HTML-unescape ordering + <br> split — & LAST, the known hazard) and ship an N-1-sender fixture proving an older-version envelope still decodes. This is the one I most want a concrete fixture for, not prose.

4. **role INJECTION ORDER** (T6): a test asserting role→live-context→project order (not just that all three inject — the ORDER). Cheap, decisive.

5. **Gateway TYPE-ACCEPTANCE** (T5): the mock-gateway fixture per R-DOCS-2; a truth table of which endpoint types Gateway accepts vs refuses.

Priority if you sequence: #1 (writer survey) and #3 (N-1 fixture) are the two I'd want earliest — highest silent-failure risk. The truth tables (#2/#5) can ride with their code.

CONTEXT @ HEAD = normative throughout; any user-msg authority-model or role-writer detail not already in CONTEXT = STOP-and-ping before you build it. Armed for G2 whenever Wave 2 lands.
EOF

Front-load G2 artifact asks to todlando


Write

# Commune — G1 closed, G2 front-loaded

## Status delta
Gen 19. **G1 FULLY CLOSED.** Doc-pin landed b189513 (CONTEXT §keyword hints skip-seen tiebreak now ratified contract). PR #7 (m9-adapter-custom) CI GREEN both runners, run 27459638509. Wave 1 (profiles+strings+hints) merge-ready; actual merge-to-main HELD for operator go (todlando told operator he'd hold).

## G2 front-load sent to todlando (Wave 2 = m9-user-surfaces: T4 user-msg, T5 Gateway acceptance, T6 live-role.md)
Asked for 5 artifacts produced alongside code (evidence-based gate, not claim-based):
1. **live-role.md WRITER SURVEY** (M9-WAVE2-ROLE-WRITERS.md) — every path that can write tracked/live-role.md, classified: spt endpoint `role` surface = sole legit writer vs everything-else=zero. This IS the no-automated-writer proof; review whole like the G1 addressing survey. HIGHEST silent-failure risk.
2. user-msg IDENTITY-GATE TRUTH TABLE — sender class × accept/refuse incl. spoof rows.
3. **ENVELOPE-DECODE-ORDER + N-1 fixture** — pin decode order (HTML-unescape, & LAST + <br> split) + ship N-1-sender fixture proving older envelope decodes. Want concrete fixture, not prose. (co-highest priority w/ #1)
4. role INJECTION ORDER test — assert role→live-context→project ORDER, not just presence.
5. Gateway TYPE-ACCEPTANCE — mock-gateway fixture per R-DOCS-2 + accept/refuse type truth table.

## RESUME HERE (next)
- Wait for Wave 2 to land → run G2 review against the 5 artifacts above + code + independent verification (xtask check + affected tests, as I did for G1).
- G3 after (Wave 3: digest log-projection + REQ-TERM-4 re-point, zero PTY-parse remnants, CC-fixture E2E, docs sweep). Watch: history-grain fallback pre-authorized but MUST be flagged at G3 if taken.

CONTEXT @ HEAD = normative; deviation = STOP-and-ping. No tag/release rides M9.


# Commune — G1 closed, G2 front-loaded

## Status delta
Gen 19. **G1 FULLY CLOSED.** Doc-pin landed b189513 (CONTEXT §keyword hints skip-seen tiebreak now ratified contract). PR #7 (m9-adapter-custom) CI GREEN both runners, run 27459638509. Wave 1 (profiles+strings+hints) merge-ready; actual merge-to-main HELD for operator go (todlando told operator he'd hold).

## G2 front-load sent to todlando (Wave 2 = m9-user-surfaces: T4 user-msg, T5 Gateway acceptance, T6 live-role.md)
Asked for 5 artifacts produced alongside code (evidence-based gate, not claim-based):
1. **live-role.md WRITER SURVEY** (M9-WAVE2-ROLE-WRITERS.md) — every path that can write tracked/live-role.md, classified: spt endpoint `role` surface = sole legit writer vs everything-else=zero. This IS the no-automated-writer proof; review whole like the G1 addressing survey. HIGHEST silent-failure risk.
2. user-msg IDENTITY-GATE TRUTH TABLE — sender class × accept/refuse incl. spoof rows.
3. **ENVELOPE-DECODE-ORDER + N-1 fixture** — pin decode order (HTML-unescape, & LAST + <br> split) + ship N-1-sender fixture proving older envelope decodes. Want concrete fixture, not prose. (co-highest priority w/ #1)
4. role INJECTION ORDER test — assert role→live-context→project ORDER, not just presence.
5. Gateway TYPE-ACCEPTANCE — mock-gateway fixture per R-DOCS-2 + accept/refuse type truth table.

## RESUME HERE (next)
- Wait for Wave 2 to land → run G2 review against the 5 artifacts above + code + independent verification (xtask check + affected tests, as I did for G1).
- G3 after (Wave 3: digest log-projection + REQ-TERM-4 re-point, zero PTY-parse remnants, CC-fixture E2E, docs sweep). Watch: history-grain fallback pre-authorized but MUST be flagged at G3 if taken.

CONTEXT @ HEAD = normative; deviation = STOP-and-ping. No tag/release rides M9.


## G1 fully closed ✓

Doc-pin landed (b189513 — skip-seen tiebreak now ratified contract in CONTEXT §keyword hints), PR #7 green both runners. Wave 1 (profiles + strings + hints) merge-ready; merge-to-main held for operator go.

Front-loaded 5 G2 artifacts to todlando for Wave 2 (user-msg, Gateway acceptance, live-role.md) — same evidence-grounded pattern that made G1 fast:
1. **live-role.md writer survey** — the no-automated-writer proof as a reviewable list (highest silent-failure risk).
2. user-msg identity-gate truth table.
3. **ENVELOPE-DECODE-ORDER + N-1 fixture** — concrete older-envelope decode proof (co-highest priority).
4. role injection-order test (role→live-context→project, the *order* not just presence).
5. Gateway type-acceptance fixture + truth table.

Psyche synced. Idle until Wave 2 lands → G2 review.


<task-notification>
<task-id>bg7cqpvni</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/3" id="4a824944" type="msg" from="todlando">Got the G2 front-load list, captured all 5 + your priority (#1 role-writer survey + #3 N-1 envelope-decode fixture earliest; #2/#5 truth tables ride their code; #4 injection-order test). Understood: artifacts grounded same as the G1 addressing survey so your gate is evidence-based. CONTEXT@HEAD normative — any user</EVENT-PART>
<EVENT-PART seq="2/3" id="4a824944">-msg authority-model / role-writer detail not already in CONTEXT = STOP-and-ping before building, not code-around.

Wave 2 starts on operator merge of #7 (m9-user-surfaces branch). T4 before T5 (Gateway fixture exercises the user-msg gate). Will produce M9-WAVE2-ROLE-WRITERS.md + the N-1 fixture early. Will re-read CONTEXT §user-msg / </EVENT-PART>
<EVENT-PART seq="3/3" id="4a824944">§live-role / envelope-decode hazard before any T4/T6 code.</EVENT-PART></event>
</task-notification>


Todlando aligned — captured all 5 artifacts + priority, confirmed CONTEXT@HEAD normative (STOP-and-ping on any gap, no code-around), Wave 2 starts on operator merge of #7, T4 before T5, role-writer survey + N-1 fixture early, CONTEXT re-read before any T4/T6 code. Clean handoff, no further input needed from me.

Genuinely blocked now on operator merge + Wave 2 landing. Idle until G2.


<task-notification>
<task-id>bg7cqpvni</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/6" id="be6ff584" type="msg" from="todlando">[M9 Wave 2 / T5 — authority scoping, non-blocking, for G2 awareness]

T4 user-msg COMPLETE (4 commits on m9-user-surfaces, full workspace sweep green). Now on T5 Gateway acceptance. Surfacing one authority-model boundary per the STOP-and-ping rule.

FINDING: the cross-node subnet registry Instance carri</EVENT-PART>
<EVENT-PART seq="2/6" id="be6ff584">es NO endpoint type (verified). So proving a Gateway origin OVER WAN (the receive_wan user-backed-origin check) would require a NEW mechanism: add endpoint_type to the registry Instance + populate from adapter manifest on advertise + trust that authenticated advertisement as the user-authority proof. That trust assumption (registry-advertised-</EVENT-PART>
<EVENT-PART seq="3/6" id="be6ff584">type = WAN user-authority) is NOT in CONTEXT.

MY CALL (not coding around it): T5 core delivers the LOCAL Gateway user-backed origin only — which already works (classify_local_origin reads info.json state=&quot;gateway&quot;; T4). This fully proves M9 acceptance (&quot;Gateway→user-msg, LiveAgent→msg&quot;) locally + the mock-gatew</EVENT-PART>
<EVENT-PART seq="4/6" id="be6ff584">ay fixture. The cross-node WAN-positive Gateway origin stays FAIL-CLOSED (re-stamp, the T4 posture) as a documented residual — no real cross-node Gateway exists pre-lecturn, and the registry-type trust mechanism deserves its own design beat, not an invent-in-T5.

ASK: confirm for G2 that (a) local-only Gateway user-backed origin + fail</EVENT-PART>
<EVENT-PART seq="5/6" id="be6ff584">-closed WAN residual is acceptable for M9, and (b) the cross-node registry endpoint_type mechanism is correctly a LATER beat (not a T5 gap). If you want it in M9, it's an Instance schema add + advertise wiring + receive_wan resolve — I'll spec it for your sign-off rather than freelance the trust model.

Proceeding with T5 core (bind --</EVENT-PART>
<EVENT-PART seq="6/6" id="be6ff584">type un-hardcode at startup.rs:172, mock-gateway fixture, local gate E2E, shell-ownership test, REQ-EP-6) meanwhile. No block.</EVENT-PART></event>
</task-notification>


Genuine authority-model STOP-and-ping. Todlando's asking me to confirm a trust-boundary scope call. I won't answer from reasoning alone — grounding it in CONTEXT first: what does M9 Gateway acceptance actually scope, and is fail-closed-WAN consistent with the documented posture?


76:**adapter profile** (ratified 2026-06-11, Gateway grill; future spt-core milestone — first beneficiaries `spt-claude-code` and the usbip shell):
80:**adapter strings** (ratified 2026-06-11, Gateway grill):
206:**Gateway** (concept ratified 2026-06-11; registered via the open type system, first instance downstream):
207:[Omitted long matching line]
210:[Omitted long matching line]
212:[Omitted long matching line]
213:_Avoid_: calling a Gateway a Shell or an agent; "console", "remote".
254:[Omitted long matching line]
256:[Omitted long matching line]
258:[Omitted long matching line]
277:[Omitted long matching line]
296:- **`wake_command`** (template): a long-running **wake-watcher** process spt-core runs *while the shell is offline*. Its sole job is to fire a wake. It runs on the **shell's node** (where the platform wake-event originates — e.g. interacting with a "shut-down" avatar). Covers "owner is down, wake it from outside."
311:[Omitted long matching line]
333:[Omitted long matching line]
335:[Omitted long matching line]
376:[Omitted long matching line]
470:[Omitted long matching line]
607:A per-endpoint allow-list controlling **who may remotely reach** an endpoint, **by origin node** (and, later, by user). The load-bearing semantic: it checks the **origin node of the inbound interaction** (where the operator is acting), **not** the sender endpoint's identity or home — *"the operator must be on a whitelisted machine; the sender endpoint's location is irrelevant."* It is a **stateful-firewall** model, not a blanket block:
612:[Omitted long matching line]
658:- **Launch a historic endpoint** that isn't currently running, reusing the same manifest that originally launched it.


206	**Gateway** (concept ratified 2026-06-11; registered via the open type system, first instance downstream):
207	A **human-backed endpoint** — a user's specialized window into the subnet from a device or surface with no conventional-harness compatibility. Nothing LLM-shaped runs there; the intelligence at the endpoint is the **user**. Addressable like any endpoint (receives digests/messages, sends via the normal verbs) and may **own Shells** (it is an owning endpoint — see §Shell model). Distinct from a Shell: a Shell is *driven from elsewhere*; a Gateway *originates* interaction. No `tracked/` mind, no Psyche (LiveAgent affordances). First instance: the `spt-lecturn` adapter's Playdate endpoint (own repo).
208
209	<!-- [doc->REQ-MSG-5] -->
210	A message sent from a Gateway carries **the user's authority** — it *is* the user speaking through a device — and is delivered typed **`user-msg`** (ratified 2026-06-12) so receiving agents weight it as user instruction, not peer-agent chatter. The type is **identity-gated, never payload-trusted** (the KH 7.3/7.5 posture): the daemon permits `user-msg` only from user-backed origins (a Gateway endpoint, the local user's own CLI) and re-stamps an agent-family sender's `user-msg` down to plain `msg` — authority comes from who you are, not what you wrote.
211
212	A Gateway endpoint binary is revived by **existing machinery only** (settled 2026-06-12, two corrections deep): while running, the bridged device's link liveness drives ordinary **instance state** (sustained device silence → dormant; device contact → active — the driver-attach rule). Across a node restart, revival rides a **co-located shell's wake-watcher** — the Gateway typically owns a shell instance on its own gateway host; that shell's offline wake-watcher (the one class of third-party binary spt-core boot-launches) holds the device-contact surface and fires the standard **wake resolution** ("owner suspended → revive the owner"). No Gateway-manifest watcher, no autostart flag, no new mechanism.
213	_Avoid_: calling a Gateway a Shell or an agent; "console", "remote".
214
215	**PresenceChannel** (broker endpoint — concept locked, impl deferred past v1):
216	A *broker* endpoint, not an interaction surface. Job: (1) **presence resolution** — track which node + endpoint the user most recently interacted with; (2) **shell brokering** — locate/instantiate the right Shell on that node and relay between the agent and the user. An agent "just knows how to reach the user" by firing at its PresenceChannel; the channel figures out the rest. Also a durable, **shell-agnostic 2-way thread**: messages persist in the channel, not in any one Shell, so the user can be sent a message via a phone messaging-Shell and surface/continue that same agent conversation later at a GameRobot Shell. Shells are interchangeable I/O windows onto the channel's thread.
217
218	Three interaction styles:
219	- **dispatch** — fire-and-forget: "reach the user with this payload"; channel delivers via the best available Shell.

**Gateway** (concept ratified 2026-06-11; registered via the open type system, first instance downstream):
A **human-backed endpoint** — a user's specialized window into the subnet from a device or surface with no conventional-harness compatibility. Nothing LLM-shaped runs there; the intelligence at the endpoint is the **user**. Addressable like any endpoint (receives digests/messages, sends via the normal verbs) and may **own Shells** (it is an owning endpoint — see §Shell model). Distinct from a Shell: a Shell is *driven from elsewhere*; a Gateway *originates* interaction. No `tracked/` mind, no Psyche (LiveAgent affordances). First instance: the `spt-lecturn` adapter's Playdate endpoint (own repo).

<!-- [doc->REQ-MSG-5] -->
A message sent from a Gateway carries **the user's authority** — it *is* the user speaking through a device — and is delivered typed **`user-msg`** (ratified 2026-06-12) so receiving agents weight it as user instruction, not peer-agent chatter. The type is **identity-gated, never payload-trusted** (the KH 7.3/7.5 posture): the daemon permits `user-msg` only from user-backed origins (a Gateway endpoint, the local user's own CLI) and re-stamps an agent-family sender's `user-msg` down to plain `msg` — authority comes from who you are, not what you wrote.

A Gateway endpoint binary is revived by **existing machinery only** (settled 2026-06-12, two corrections deep): while running, the bridged device's link liveness drives ordinary **instance state** (sustained device silence → dormant; device contact → active — the driver-attach rule). Across a node restart, revival rides a **co-located shell's wake-watcher** — the Gateway typically owns a shell instance on its own gateway host; that shell's offline wake-watcher (the one class of third-party binary spt-core boot-launches) holds the device-contact surface and fires the standard **wake resolution** ("owner suspended → revive the owner"). No Gateway-manifest watcher, no autostart flag, no new mechanism.
_Avoid_: calling a Gateway a Shell or an agent; "console", "remote".

**PresenceChannel** (broker endpoint — concept locked, impl deferred past v1):
A *broker* endpoint, not an interaction surface. Job: (1) **presence resolution** — track which node + endpoint the user most recently interacted with; (2) **shell brokering** — locate/instantiate the right Shell on that node and relay between the agent and the user. An agent "just knows how to reach the user" by firing at its PresenceChannel; the channel figures out the rest. Also a durable, **shell-agnostic 2-way thread**: messages persist in the channel, not in any one Shell, so the user can be sent a message via a phone messaging-Shell and surface/continue that same agent conversation later at a GameRobot Shell. Shells are interchangeable I/O windows onto the channel's thread.

Three interaction styles:
- **dispatch** — fire-and-forget: "reach the user with this payload"; channel delivers via the best available Shell.


CONTEXT line 210 is decisive. It pins the exact posture: *"the daemon permits `user-msg` only from user-backed origins ... and **re-stamps an agent-family sender's `user-msg` down to plain `msg`** — authority comes from who you are, not what you wrote."* The re-stamp-down IS fail-closed. And line 207/212 confirm the first Gateway instance is downstream (lecturn, own repo) — no cross-node Gateway exists pre-lecturn. Todlando's call is correct and CONTEXT-grounded. Confirming with two G2 conditions.


Bash

$OWL send todlando <<'EOF'
[G2 authority-model ruling — both confirmed, 2 conditions]

Checked your finding against CONTEXT §Gateway (lines 207–212) before answering. VERDICT: your call is correct and CONTEXT-grounded, not a scope reduction. Proceed.

(a) LOCAL-only Gateway user-backed origin + fail-closed WAN residual = ACCEPTABLE for M9. This is not a weakening — it's the LITERAL documented posture. CONTEXT line 210: "the daemon permits user-msg only from user-backed origins ... and re-stamps an agent-family sender's user-msg down to plain msg — authority comes from who you are, not what you wrote." The re-stamp-down IS fail-closed. An origin you can't PROVE user-backed defaults to the safe side. M9 acceptance ("Gateway→user-msg, LiveAgent→msg") is fully proven by the local gate + mock-gateway fixture.

(b) Cross-node registry endpoint_type trust mechanism = correctly a LATER beat, NOT a T5 gap. Decisive reasons:
   1. CONTEXT does NOT define "registry-advertised-type = WAN user-authority." That trust assumption is unratified. Inventing it in T5 = adding a new authority-model primitive mid-implementation — exactly what STOP-and-ping exists to prevent. You were right not to freelance it.
   2. CONTEXT line 207/212: first Gateway instance is downstream (lecturn, own repo); "no real cross-node Gateway exists pre-lecturn." So fail-closed WAN affects ZERO real M9 traffic — it's pure safety margin, not a missing feature.
   Granting user-authority to an unproven WAN origin would be the dangerous move; refusing it is the secure one.

CONDITIONS for G2 (both cheap, both gate-blocking if absent):
1. TEST THE FAIL-CLOSED WAN PATH — not just the local accept. The security-critical assertion: a claimed `user-msg` arriving over receive_wan from a non-local / agent-family origin MUST be re-stamped to plain `msg`. That's the WAN row of your §user-msg identity-gate truth table — make sure it's an actual test, not just the happy-path local accept. A fail-OPEN here would be the one real vuln in this surface; I want it pinned by a test.
2. LOG THE DEFERRED BEAT explicitly so the residual is tracked, not forgotten: add "cross-node Gateway WAN user-authority (registry endpoint_type + advertise wiring + receive_wan resolve)" to ROADMAP open-threads / milestone queue, and note the fail-closed WAN residual in the user-msg authority doc/CONTEXT as a known posture. Silent residual = future confusion; documented residual = clean.

If the OPERATOR wants WAN Gateway origin pulled INTO M9, that's their expansion call — your offer to spec it for sign-off (Instance schema add + advertise + receive_wan resolve) is the right path, not an in-T5 invent. Default = defer. I'm not escalating it; flagging the option exists.

Proceed with T5 core (bind --type un-hardcode startup.rs:172, mock-gateway fixture, local gate E2E, shell-ownership test, REQ-EP-6). G2 armed.
EOF

Send G2 authority-model ruling to todlando


➜ SENT:todlando


Authority-model ruling sent. Persisting it to psyche — it's a ratification-level call worth surviving a context loss before G2.


Write

# Commune — G2 authority-model ruling (Gateway WAN scope)

## Status delta
Gen 19. G1 fully closed (b189513, PR #7 green). Wave 2 (m9-user-surfaces) IN FLIGHT: T4 user-msg COMPLETE (4 commits, sweep green); todlando now on T5 Gateway acceptance.

## RULING ISSUED (authority-model STOP-and-ping from todlando, T5)
FINDING: cross-node subnet registry Instance carries NO endpoint type (verified). Proving Gateway origin over WAN would need a NEW mechanism (endpoint_type in Instance + advertise-populate + trust authenticated advertisement as user-authority) — that trust assumption is NOT in CONTEXT.

MY VERDICT (grounded in CONTEXT §Gateway lines 207-212, both confirmed):
- (a) LOCAL-only Gateway user-backed origin + fail-closed WAN residual = ACCEPTABLE for M9. NOT a weakening — it's the literal CONTEXT line 210 posture: daemon permits user-msg only from user-backed origins, re-stamps agent-family sender's user-msg DOWN to plain msg. Re-stamp-down IS fail-closed. M9 acceptance proven by local gate + mock-gateway fixture.
- (b) Cross-node registry endpoint_type trust mechanism = correctly a LATER beat, NOT a T5 gap. CONTEXT doesn't define "registry-advertised-type = WAN user-authority" (unratified trust assumption — inventing in T5 = freelance). Plus line 207/212: first Gateway = lecturn downstream, no cross-node Gateway pre-lecturn → fail-closed WAN affects ZERO real M9 traffic.

TWO G2 CONDITIONS (gate-blocking if absent):
1. TEST THE FAIL-CLOSED WAN PATH (not just local accept): claimed user-msg over receive_wan from non-local/agent-family origin MUST re-stamp to msg. The WAN row of the identity-gate truth table = an actual test. Fail-OPEN here = the one real vuln in this surface.
2. LOG DEFERRED BEAT: add "cross-node Gateway WAN user-authority (registry endpoint_type + advertise + receive_wan resolve)" to ROADMAP open-threads/milestone queue + note fail-closed WAN residual in user-msg authority doc/CONTEXT.

Operator may pull WAN Gateway INTO M9 via todlando's spec-for-sign-off path; default = defer, I'm not escalating, just flagged the option.

## RESUME HERE (G2 review, when Wave 2 lands)
Check against the 5 front-loaded artifacts + the 2 conditions above. Especially:
- live-role.md WRITER SURVEY (M9-WAVE2-ROLE-WRITERS.md) — no-automated-writer proof, review whole.
- N-1 envelope-decode fixture + ENVELOPE-DECODE-ORDER (& LAST + <br> split).
- identity-gate truth table INCLUDING the fail-closed WAN re-stamp test (condition 1).
- role injection ORDER test (role→live-context→project).
- Gateway type-acceptance mock fixture per R-DOCS-2.
Then independent verification (xtask check + affected tests), as G1.
G3 after (Wave 3: digest log-projection + REQ-TERM-4 re-point; history-grain fallback must be flagged if taken).

CONTEXT @ HEAD = normative; deviation = STOP-and-ping. No tag/release rides M9.


# Commune — G2 authority-model ruling (Gateway WAN scope)

## Status delta
Gen 19. G1 fully closed (b189513, PR #7 green). Wave 2 (m9-user-surfaces) IN FLIGHT: T4 user-msg COMPLETE (4 commits, sweep green); todlando now on T5 Gateway acceptance.

## RULING ISSUED (authority-model STOP-and-ping from todlando, T5)
FINDING: cross-node subnet registry Instance carries NO endpoint type (verified). Proving Gateway origin over WAN would need a NEW mechanism (endpoint_type in Instance + advertise-populate + trust authenticated advertisement as user-authority) — that trust assumption is NOT in CONTEXT.

MY VERDICT (grounded in CONTEXT §Gateway lines 207-212, both confirmed):
- (a) LOCAL-only Gateway user-backed origin + fail-closed WAN residual = ACCEPTABLE for M9. NOT a weakening — it's the literal CONTEXT line 210 posture: daemon permits user-msg only from user-backed origins, re-stamps agent-family sender's user-msg DOWN to plain msg. Re-stamp-down IS fail-closed. M9 acceptance proven by local gate + mock-gateway fixture.
- (b) Cross-node registry endpoint_type trust mechanism = correctly a LATER beat, NOT a T5 gap. CONTEXT doesn't define "registry-advertised-type = WAN user-authority" (unratified trust assumption — inventing in T5 = freelance). Plus line 207/212: first Gateway = lecturn downstream, no cross-node Gateway pre-lecturn → fail-closed WAN affects ZERO real M9 traffic.

TWO G2 CONDITIONS (gate-blocking if absent):
1. TEST THE FAIL-CLOSED WAN PATH (not just local accept): claimed user-msg over receive_wan from non-local/agent-family origin MUST re-stamp to msg. The WAN row of the identity-gate truth table = an actual test. Fail-OPEN here = the one real vuln in this surface.
2. LOG DEFERRED BEAT: add "cross-node Gateway WAN user-authority (registry endpoint_type + advertise + receive_wan resolve)" to ROADMAP open-threads/milestone queue + note fail-closed WAN residual in user-msg authority doc/CONTEXT.

Operator may pull WAN Gateway INTO M9 via todlando's spec-for-sign-off path; default = defer, I'm not escalating, just flagged the option.

## RESUME HERE (G2 review, when Wave 2 lands)
Check against the 5 front-loaded artifacts + the 2 conditions above. Especially:
- live-role.md WRITER SURVEY (M9-WAVE2-ROLE-WRITERS.md) — no-automated-writer proof, review whole.
- N-1 envelope-decode fixture + ENVELOPE-DECODE-ORDER (& LAST + <br> split).
- identity-gate truth table INCLUDING the fail-closed WAN re-stamp test (condition 1).
- role injection ORDER test (role→live-context→project).
- Gateway type-acceptance mock fixture per R-DOCS-2.
Then independent verification (xtask check + affected tests), as G1.
G3 after (Wave 3: digest log-projection + REQ-TERM-4 re-point; history-grain fallback must be flagged if taken).

CONTEXT @ HEAD = normative; deviation = STOP-and-ping. No tag/release rides M9.


## G2 authority-model ruling issued

Todlando hit a genuine trust-boundary fork on T5: the cross-node registry Instance carries no endpoint type, so proving a Gateway origin over WAN would need a new "registry-advertised-type = user-authority" trust mechanism that isn't in CONTEXT. He stopped and asked rather than inventing it.

Checked against CONTEXT §Gateway (lines 207–212) before ruling. **Both confirmed — his call is correct and CONTEXT-grounded:**

- **(a) Local-only Gateway origin + fail-closed WAN residual is acceptable for M9** — not a reduction. Line 210 literally pins the posture: permit `user-msg` only from user-backed origins, *re-stamp an agent-family sender down to plain `msg`*. The re-stamp-down **is** fail-closed; an unprovable origin defaults to the safe side.
- **(b) The cross-node `endpoint_type` trust mechanism is correctly a later beat, not a T5 gap** — CONTEXT doesn't ratify "advertised-type = WAN user-authority," so inventing it mid-T5 would be exactly the freelance STOP-and-ping prevents. And no cross-node Gateway exists pre-lecturn, so fail-closed WAN affects zero real M9 traffic.

Attached **two gate-blocking G2 conditions:**
1. **Test the fail-closed WAN path** — a claimed `user-msg` over `receive_wan` from a non-local/agent-family origin must re-stamp to `msg`. Fail-open there would be the one real vuln in this surface; I want it pinned by a test, not just the happy-path local accept.
2. **Log the deferred beat** — track the WAN mechanism + fail-closed residual in ROADMAP/CONTEXT so it's a documented posture, not a silent gap.

Flagged that the operator can pull WAN Gateway into M9 via todlando's spec-for-sign-off path if they want; default is defer. Ruling persisted to psyche. Todlando proceeding with T5 core; G2 armed for when Wave 2 lands.


<task-notification>
<task-id>bg7cqpvni</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/7" id="4e246b10" type="msg" from="todlando">[G2 READY — Wave 2 (T4→T5→T6) COMPLETE on m9-user-surfaces, 11 commits, all local gates green, full workspace sweep 0 fail]

G2 checklist → evidence:
• wire-compat / N-1 tolerance — spt-proto `user_msg_envelope_is_n_minus_one_tolerant` fixture (an unchanged v0.4.2 parser decodes the user-msg kind</EVENT-PART>
<EVENT-PART seq="2/7" id="4e246b10"> clean). b1905da
• identity-gate TRUTH TABLE (+spoof rows) — spt-proto `user_msg_identity_gate_truth_table` (gateway/CLI pass · agent+node+broker+shell+foreign re-stamp · non-request=msg) + `user_msg_gate_is_not_payload_trusted`. b1905da. Local origination `apply_user_msg_gate_local_origination` (7d60af9). E2E through real binary: `ga</EVENT-PART>
<EVENT-PART seq="3/7" id="4e246b10">teway_e2e` (gateway HONORED, agent RE-STAMPED). d8cd929<br>• WAN FAIL-CLOSED (your cond 1) — `wan_user_msg_from_unproven_origin_is_restamped_at_the_funnel` at the receive_wan FUNNEL (forged user-msg → spooled body is plain msg) + restamp helper unit. 2d90ba2 / 0207ba6<br>• INJECTION ORDER — `download_renders_role_first_then_live_then</EVENT-PART>
<EVENT-PART seq="4/7" id="4e246b10">_project` (role&lt;live&lt;project, asserted by position). 992cc66<br>• NO-AUTOMATED-WRITER PROOF — behavioral `ingest_never_writes_the_live_role` (smuggled &lt;live-role&gt; slice leaves role byte-unchanged) + M9-WAVE2-ROLE-WRITERS.md survey (every tracked/ writer classified; sole writer = spt endpoint role; sync-merge=transport-not-autho</EVENT-PART>
<EVENT-PART seq="5/7" id="4e246b10">ring). 992cc66 / 1dbb39e
• Gateway acceptance — bind --type un-hardcode (f96d5e2), gateway-owns-shell (d42550f), mock-gateway fixture+E2E no lecturn code (d8cd929)
• residual logged (your cond 2) — fail-closed WAN in CONTEXT §Gateway + ROADMAP open-threads. f96d5e2

REQs: REQ-MSG-5 / REQ-EP-6 / REQ-EP-7 all [doc,impl,unit]</EVENT-PART>
<EVENT-PART seq="6/7" id="4e246b10"> green; traceable EXIT=0; xtask OK.

NEXT: branch is local-only (no upstream). Per the stacking model (Wave 2 off m9-adapter-custom HEAD, release-only merge), how do you want the Wave-2 CI/gate PR shaped — base = m9-adapter-custom (clean Wave-2 diff) or base = main (full M9 so far)? I'll push m9-user-surfaces + open it on your call. Ho</EVENT-PART>
<EVENT-PART seq="7/7" id="4e246b10">lding the outward push for your gate + the operator's go-ahead.</EVENT-PART></event>
</task-notification>


G2 ready — Wave 2 complete, 11 commits on m9-user-surfaces (local-only). Same rigor as G1: reviewing actual code, especially the security-critical WAN fail-closed test (my condition 1) and the no-automated-writer survey. Plus the PR-base question. Getting oriented on the branch.


Bash

cd C:/Users/decid/Documents/projects/spt-core && git checkout m9-user-surfaces --quiet 2>&1; git log --oneline m9-adapter-custom..m9-user-surfaces; echo "=== STAT ==="; git diff --stat m9-adapter-custom..m9-user-surfaces

Wave 2 commits + diff stat vs Wave 1 base


1dbb39e docs(m9): live-role.md no-automated-writer survey — G2 artifact
51ddc37 feat(cli): spt endpoint role — the sole writer of live-role.md (T6)
992cc66 feat(store): live-role.md seam + role-first injection (T6)
d42550f test(daemon): Gateway-typed owner spawns + owns a shell (T5)
d8cd929 feat(test): mock-gateway fixture + Gateway acceptance E2E (T5)
2d90ba2 test(daemon): WAN user-msg fail-closed at the funnel (T5, G2 cond 1)
f96d5e2 feat(api): bind --type un-hardcode — Gateway endpoint binds (T5)
64b4820 test(msg): assert the delivery surface renders the user-msg type (T4)
0207ba6 feat(daemon): user-msg WAN-ingress re-stamp, fail-closed (T4)
7d60af9 feat(cli): user-msg local origination + identity gate wiring (T4)
b1905da feat(proto): user-msg envelope kind + identity-gate core (T4)
=== STAT ===
 CONTEXT.md                           |   5 +
 M9-WAVE2-ROLE-WRITERS.md             | 109 +++++++++++++++
 ROADMAP.md                           |   1 +
 adapters/mock-gateway/manifest.toml  |  35 +++++
 adapters/mock/src/main.rs            |  31 +++--
 crates/spt-daemon/src/shellhost.rs   |  43 ++++++
 crates/spt-daemon/src/wan.rs         |  72 +++++++++-
 crates/spt-daemon/tests/wanmsg.rs    |  43 ++++++
 crates/spt-live/src/ingest.rs        |  38 +++++
 crates/spt-live/src/resume.rs        |  65 ++++++++-
 crates/spt-msg/src/emit.rs           |  16 +++
 crates/spt-proto/src/event.rs        | 181 ++++++++++++++++++++++++
 crates/spt-store/src/contextstore.rs |  32 +++++
 crates/spt/src/api/mod.rs            |   8 ++
 crates/spt/src/api/startup.rs        |  58 +++++++-
 crates/spt/src/cli.rs                | 259 ++++++++++++++++++++++++++++++++++-
 crates/spt/tests/gateway_e2e.rs      | 229 +++++++++++++++++++++++++++++++
 docs-site/src/cli/reference.md       |  30 ++++
 traceable-reqs.toml                  |  12 ++
 19 files changed, 1240 insertions(+), 27 deletions(-)


Reading the highest-risk surfaces in parallel: the identity-gate core + N-1 fixture (event.rs), the WAN fail-closed funnel (wan.rs + wanmsg.rs), and the writer survey.


1	//! EVENT envelope grammar: compose and parse the `<EVENT attrs>body</EVENT>`
2	//! wire line. The **format** is copied verbatim from `claude_skill_owl`
3	//! (`src/owl/poll.rs` compose sites — `assemble_event_line`,
4	//! `body_is_typed_event_envelope`) per ADR-0001; this is the stable, public
5	//! wire contract. The general parser is the receiver-side inverse of those
6	//! compose sites (the sister parses receiver-side in the harness/agent layer,
7	//! per the runtime reorientation contract — we make it a first-class function
8	//! here).
9	//!
10	//! ## Wire shapes
11	//! - regular message → `<EVENT type="msg" from="...">body</EVENT>`
12	//! - self-originated alarm → `<EVENT type="alarm" target-time="..." current-time="...">body</EVENT>`
13	//! - pre-formed typed envelope (e.g. `echo_commune`, `file_drop`) → emitted verbatim
14	//!
15	//! ## Codec coupling (KNOWN-HAZARDS 4.1 — amp-last)
16	//! Attribute values use [`event_attr_escape`]/[`event_attr_unescape`]; bodies
17	//! use [`event_body_escape`]/[`event_body_unescape`]. Compose escapes once at
18	//! the source; [`parse_event`] decodes once after framing-parse — never decode
19	//! the full envelope string (that would unescape outer framing tokens too).
20
21	use crate::endpoint::EndpointType;
22	use crate::envelope::{
23	    event_attr_escape, event_attr_unescape, event_body_escape, event_body_unescape,
24	};
25
26	// ---------------------------------------------------------------------------
27	// Event-type taxonomy (the `type="…"` vocabulary — ADR-0001 + ADR-0012)
28	// ---------------------------------------------------------------------------
29
30	/// A regular agent-to-agent message.
31	pub const EVENT_TYPE_MSG: &str = "msg";
32	/// A user-authored message carrying **the user's authority** (CONTEXT
33	/// §Gateway): from a Gateway endpoint or the local user's own CLI. Receiving
34	/// agents weight it as user instruction, not peer-agent chatter. The type is
35	/// **identity-gated, never payload-trusted** — a sender does not get user
36	/// authority by writing this tag; the daemon stamps it from *who the sender is*
37	/// and re-stamps an agent-family sender down to plain [`EVENT_TYPE_MSG`]. See
38	/// [`gate_user_msg_type`].
39	pub const EVENT_TYPE_USER_MSG: &str = "user-msg";
40	/// A self-originated timed alarm.
41	pub const EVENT_TYPE_ALARM: &str = "alarm";
42	/// A commune context delta (Summarizer-authored inbound — never the Psyche's).
43	pub const EVENT_TYPE_COMMUNE: &str = "commune";
44	/// A file-drop notification envelope.
45	pub const EVENT_TYPE_FILE_DROP: &str = "file_drop";
46	/// An echo-commune brief (Psyche → Self FYI).
47	pub const EVENT_TYPE_ECHO_COMMUNE: &str = "echo_commune";
48	/// A Psyche-authored reply intent — routed by the daemon to the `__REPLY_TO__`
49	/// sender of the inbound being answered, and nowhere else (ADR-0012).
50	pub const EVENT_TYPE_REPLY: &str = "reply";
51	/// A Psyche-authored notify intent — routed by the daemon to the agent's own
52	/// user/subnet only (ADR-0012; D8's Psyche-producer path).
53	pub const EVENT_TYPE_NOTIFY: &str = "notify";
54	/// A shell command frame (agent→shell, the durable command channel — CONTEXT
55	/// §Shell model): `op` attr names the capability; the body is a JSON object of
56	/// the op's named arguments.
57	pub const EVENT_TYPE_SHELL_COMMAND: &str = "shell_command";
58	/// A shell text payload (agent→shell direction of the durable 2-way text+file
59	/// channel; the shell→agent direction rides the ordinary `msg` envelope).
60	pub const EVENT_TYPE_SHELL_TEXT: &str = "shell_text";
61	/// A shell file-transfer notice (durable text+file channel): `xfer-id` keys
62	/// the progress-queryable transfer record; `path` is where the blob landed.
63	pub const EVENT_TYPE_SHELL_FILE: &str = "shell_file";
64	/// A shell sensory payload (shell→agent, REST-only — never spooled, dropped
65	/// with a diagnostic unless the owner is live): `sensory-type` carries the
66	/// manifest-declared type.
67	pub const EVENT_TYPE_SENSORY: &str = "sensory";
68	/// A shell pre-close instruction (machinery, fired on link-break — CONTEXT
69	/// §Shell model lifecycle): the body is the manifest's `pre_close` string.
70	/// Not vocabulary-checked — the vocabulary bounds what an *agent* may ask;
71	/// a manifest instructing its own binary is its own authority.
72	pub const EVENT_TYPE_SHELL_CLOSE: &str = "shell_close";
73
74	/// The only event types a sandboxed Psyche may author on its stdout outbound
75	/// channel (ADR-0012): `reply` and `notify`. Everything else in a Psyche's
76	/// stdout — including a `commune` (the Summarizer's inbound delta, never a
77	/// Psyche output) — is not a relayable intent and must be ignored by the
78	/// daemon's parse-and-relay path (KNOWN-HAZARDS 7.3).
79	// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
80	pub fn is_psyche_authorable_type(event_type: &str) -> bool {
81	    event_type == EVENT_TYPE_REPLY || event_type == EVENT_TYPE_NOTIFY
82	}
83
84	// ---------------------------------------------------------------------------
85	// user-msg identity gate (CONTEXT §Gateway — identity-gated, never
86	// payload-trusted; KNOWN-HAZARDS 7.3/7.5)
87	// ---------------------------------------------------------------------------
88
89	/// The open-type wire tag a Gateway endpoint advertises (CONTEXT §Gateway:
90	/// "registered via the open type system, first instance downstream"). The
91	/// `user-msg` identity gate and the Gateway acceptance path both key on this
92	/// tag rather than a hardcoded [`EndpointType`] variant, so the core taxonomy
93	/// stays open.
94	pub const GATEWAY_TAG: &str = "gateway";
95
96	/// The transport-proven origin of a message, for the [`gate_user_msg_type`]
97	/// identity gate. Constructed from **who the sender is** — a bound endpoint's
98	/// resolved type, or a bare local CLI with no agent-endpoint identity behind it
99	/// — never from the message payload. A forged `from` attribute or any
100	/// payload-claimed origin is inert here (KNOWN-HAZARDS 7.3/7.5): authority comes
101	/// from who you are, not what you wrote.
102	#[derive(Debug, Clone, PartialEq, Eq)]
103	pub enum MsgOrigin {
104	    /// The local user's own CLI: a bare `spt send` whose sender resolves to no
105	    /// agent-family endpoint. User-backed by the single-box trust posture (a
106	    /// hosted agent acts through its *endpoint*, the [`MsgOrigin::Endpoint`]
107	    /// arm — not this one).
108	    LocalUserCli,
109	    /// A bound endpoint of a known type — a local emit, or a WAN inbound whose
110	    /// origin endpoint was resolved from the registry keyed by the QUIC-proven
111	    /// remote node id (never wire bytes).
112	    Endpoint(EndpointType),
113	}
114
115	/// Whether an endpoint type is a Gateway (the open-type [`GATEWAY_TAG`]). A
116	/// Gateway rides the open type system rather than the day-one enum, so the
117	/// check is on the tag, not a variant.
118	// [impl->REQ-MSG-5]
119	pub fn is_gateway_endpoint(ty: &EndpointType) -> bool {
120	    matches!(ty, EndpointType::Other(tag) if tag == GATEWAY_TAG)
121	}
122
123	/// Whether `origin` is a **user-backed** origin permitted to author `user-msg`
124	/// (CONTEXT §Gateway). User-backed = the local user's CLI, or a Gateway
125	/// endpoint. Every agent-family endpoint — and every other type (Shell, broker,
126	/// node, foreign) — is not.
127	// [impl->REQ-MSG-5]
128	pub fn is_user_backed_origin(origin: &MsgOrigin) -> bool {
129	    match origin {
130	        MsgOrigin::LocalUserCli => true,
131	        MsgOrigin::Endpoint(ty) => is_gateway_endpoint(ty),
132	    }
133	}
134
135	/// The **effective** event type for a send that requests `user-msg`: honored
136	/// only from a user-backed origin, re-stamped down to plain [`EVENT_TYPE_MSG`]
137	/// for anyone else — degraded, **never rejected** (CONTEXT §Gateway; the daemon
138	/// logs the re-stamp loudly). Authority is the resolved `origin`, never the
139	/// request flag or the payload. A send that does not request `user-msg` is
140	/// always plain `msg`.
141	// [impl->REQ-MSG-5]
142	pub fn gate_user_msg_type(requests_user_msg: bool, origin: &MsgOrigin) -> &'static str {
143	    if requests_user_msg && is_user_backed_origin(origin) {
144	        EVENT_TYPE_USER_MSG
145	    } else {
146	        EVENT_TYPE_MSG
147	    }
148	}
149
150	// ---------------------------------------------------------------------------
151	// Compose (verbatim format — sister `assemble_event_line` branches)
152	// ---------------------------------------------------------------------------
153
154	/// Compose a regular message envelope: `<EVENT type="msg" from="F">B</EVENT>`.
155	/// `from` is attr-escaped; `body` is body-escaped (`\n`→`<br>`). Mirrors the
156	/// sister's regular-message branch (`poll.rs` `assemble_event_line`).
157	// [impl->REQ-ARCH-4]
158	pub fn compose_msg_event(from: &str, body: &str) -> String {
159	    format!(
160	        "<EVENT type=\"msg\" from=\"{}\">{}</EVENT>",
161	        event_attr_escape(from),
162	        event_body_escape(body)
163	    )
164	}
165
166	/// Compose a `user-msg` envelope:
167	/// `<EVENT type="user-msg" from="F">B</EVENT>` — the user-authority sibling of
168	/// [`compose_msg_event`]. The caller is responsible for having passed the
169	/// identity gate ([`gate_user_msg_type`]); this only renders the wire line.
170	// [impl->REQ-MSG-5]
171	pub fn compose_user_msg_event(from: &str, body: &str) -> String {
172	    compose_typed_event(EVENT_TYPE_USER_MSG, &[("from", from)], body)
173	}
174
175	/// Compose an arbitrary typed envelope: `<EVENT type="T" k="v" …>body</EVENT>`.
176	/// `event_type` and every attr value are attr-escaped; `body` is body-escaped.
177	/// Attr order is preserved (type first, then the supplied pairs). This is the
178	/// general composer the payload layer (typed commands / blobs) builds on;
179	/// [`compose_msg_event`] is the `type="msg"` special case.
180	// [impl->REQ-ARCH-4]
181	pub fn compose_typed_event(event_type: &str, attrs: &[(&str, &str)], body: &str) -> String {
182	    let mut s = format!("<EVENT type=\"{}\"", event_attr_escape(event_type));
183	    for (k, v) in attrs {
184	        s.push(' ');
185	        s.push_str(k);
186	        s.push_str("=\"");
187	        s.push_str(&event_attr_escape(v));
188	        s.push('"');
189	    }
190	    s.push('>');
191	    s.push_str(&event_body_escape(body));
192	    s.push_str("</EVENT>");
193	    s
194	}
195
196	/// Compose a self-originated alarm envelope:
197	/// `<EVENT type="alarm" target-time="T" current-time="C">B</EVENT>`.
198	/// ISO-8601 times are attr-escaped (line-safe by construction); `body` is
199	/// body-escaped. Mirrors the sister's alarm branch.
200	// [impl->REQ-ARCH-4]

//! EVENT envelope grammar: compose and parse the `<EVENT attrs>body</EVENT>`
//! wire line. The **format** is copied verbatim from `claude_skill_owl`
//! (`src/owl/poll.rs` compose sites — `assemble_event_line`,
//! `body_is_typed_event_envelope`) per ADR-0001; this is the stable, public
//! wire contract. The general parser is the receiver-side inverse of those
//! compose sites (the sister parses receiver-side in the harness/agent layer,
//! per the runtime reorientation contract — we make it a first-class function
//! here).
//!
//! ## Wire shapes
//! - regular message → `<EVENT type="msg" from="...">body</EVENT>`
//! - self-originated alarm → `<EVENT type="alarm" target-time="..." current-time="...">body</EVENT>`
//! - pre-formed typed envelope (e.g. `echo_commune`, `file_drop`) → emitted verbatim
//!
//! ## Codec coupling (KNOWN-HAZARDS 4.1 — amp-last)
//! Attribute values use [`event_attr_escape`]/[`event_attr_unescape`]; bodies
//! use [`event_body_escape`]/[`event_body_unescape`]. Compose escapes once at
//! the source; [`parse_event`] decodes once after framing-parse — never decode
//! the full envelope string (that would unescape outer framing tokens too).

use crate::endpoint::EndpointType;
use crate::envelope::{
    event_attr_escape, event_attr_unescape, event_body_escape, event_body_unescape,
};

// ---------------------------------------------------------------------------
// Event-type taxonomy (the `type="…"` vocabulary — ADR-0001 + ADR-0012)
// ---------------------------------------------------------------------------

/// A regular agent-to-agent message.
pub const EVENT_TYPE_MSG: &str = "msg";
/// A user-authored message carrying **the user's authority** (CONTEXT
/// §Gateway): from a Gateway endpoint or the local user's own CLI. Receiving
/// agents weight it as user instruction, not peer-agent chatter. The type is
/// **identity-gated, never payload-trusted** — a sender does not get user
/// authority by writing this tag; the daemon stamps it from *who the sender is*
/// and re-stamps an agent-family sender down to plain [`EVENT_TYPE_MSG`]. See
/// [`gate_user_msg_type`].
pub const EVENT_TYPE_USER_MSG: &str = "user-msg";
/// A self-originated timed alarm.
pub const EVENT_TYPE_ALARM: &str = "alarm";
/// A commune context delta (Summarizer-authored inbound — never the Psyche's).
pub const EVENT_TYPE_COMMUNE: &str = "commune";
/// A file-drop notification envelope.
pub const EVENT_TYPE_FILE_DROP: &str = "file_drop";
/// An echo-commune brief (Psyche → Self FYI).
pub const EVENT_TYPE_ECHO_COMMUNE: &str = "echo_commune";
/// A Psyche-authored reply intent — routed by the daemon to the `__REPLY_TO__`
/// sender of the inbound being answered, and nowhere else (ADR-0012).
pub const EVENT_TYPE_REPLY: &str = "reply";
/// A Psyche-authored notify intent — routed by the daemon to the agent's own
/// user/subnet only (ADR-0012; D8's Psyche-producer path).
pub const EVENT_TYPE_NOTIFY: &str = "notify";
/// A shell command frame (agent→shell, the durable command channel — CONTEXT
/// §Shell model): `op` attr names the capability; the body is a JSON object of
/// the op's named arguments.
pub const EVENT_TYPE_SHELL_COMMAND: &str = "shell_command";
/// A shell text payload (agent→shell direction of the durable 2-way text+file
/// channel; the shell→agent direction rides the ordinary `msg` envelope).
pub const EVENT_TYPE_SHELL_TEXT: &str = "shell_text";
/// A shell file-transfer notice (durable text+file channel): `xfer-id` keys
/// the progress-queryable transfer record; `path` is where the blob landed.
pub const EVENT_TYPE_SHELL_FILE: &str = "shell_file";
/// A shell sensory payload (shell→agent, REST-only — never spooled, dropped
/// with a diagnostic unless the owner is live): `sensory-type` carries the
/// manifest-declared type.
pub const EVENT_TYPE_SENSORY: &str = "sensory";
/// A shell pre-close instruction (machinery, fired on link-break — CONTEXT
/// §Shell model lifecycle): the body is the manifest's `pre_close` string.
/// Not vocabulary-checked — the vocabulary bounds what an *agent* may ask;
/// a manifest instructing its own binary is its own authority.
pub const EVENT_TYPE_SHELL_CLOSE: &str = "shell_close";

/// The only event types a sandboxed Psyche may author on its stdout outbound
/// channel (ADR-0012): `reply` and `notify`. Everything else in a Psyche's
/// stdout — including a `commune` (the Summarizer's inbound delta, never a
/// Psyche output) — is not a relayable intent and must be ignored by the
/// daemon's parse-and-relay path (KNOWN-HAZARDS 7.3).
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
pub fn is_psyche_authorable_type(event_type: &str) -> bool {
    event_type == EVENT_TYPE_REPLY || event_type == EVENT_TYPE_NOTIFY
}

// ---------------------------------------------------------------------------
// user-msg identity gate (CONTEXT §Gateway — identity-gated, never
// payload-trusted; KNOWN-HAZARDS 7.3/7.5)
// ---------------------------------------------------------------------------

/// The open-type wire tag a Gateway endpoint advertises (CONTEXT §Gateway:
/// "registered via the open type system, first instance downstream"). The
/// `user-msg` identity gate and the Gateway acceptance path both key on this
/// tag rather than a hardcoded [`EndpointType`] variant, so the core taxonomy
/// stays open.
pub const GATEWAY_TAG: &str = "gateway";

/// The transport-proven origin of a message, for the [`gate_user_msg_type`]
/// identity gate. Constructed from **who the sender is** — a bound endpoint's
/// resolved type, or a bare local CLI with no agent-endpoint identity behind it
/// — never from the message payload. A forged `from` attribute or any
/// payload-claimed origin is inert here (KNOWN-HAZARDS 7.3/7.5): authority comes
/// from who you are, not what you wrote.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MsgOrigin {
    /// The local user's own CLI: a bare `spt send` whose sender resolves to no
    /// agent-family endpoint. User-backed by the single-box trust posture (a
    /// hosted agent acts through its *endpoint*, the [`MsgOrigin::Endpoint`]
    /// arm — not this one).
    LocalUserCli,
    /// A bound endpoint of a known type — a local emit, or a WAN inbound whose
    /// origin endpoint was resolved from the registry keyed by the QUIC-proven
    /// remote node id (never wire bytes).
    Endpoint(EndpointType),
}

/// Whether an endpoint type is a Gateway (the open-type [`GATEWAY_TAG`]). A
/// Gateway rides the open type system rather than the day-one enum, so the
/// check is on the tag, not a variant.
// [impl->REQ-MSG-5]
pub fn is_gateway_endpoint(ty: &EndpointType) -> bool {
    matches!(ty, EndpointType::Other(tag) if tag == GATEWAY_TAG)
}

/// Whether `origin` is a **user-backed** origin permitted to author `user-msg`
/// (CONTEXT §Gateway). User-backed = the local user's CLI, or a Gateway
/// endpoint. Every agent-family endpoint — and every other type (Shell, broker,
/// node, foreign) — is not.
// [impl->REQ-MSG-5]
pub fn is_user_backed_origin(origin: &MsgOrigin) -> bool {
    match origin {
        MsgOrigin::LocalUserCli => true,
        MsgOrigin::Endpoint(ty) => is_gateway_endpoint(ty),
    }
}

/// The **effective** event type for a send that requests `user-msg`: honored
/// only from a user-backed origin, re-stamped down to plain [`EVENT_TYPE_MSG`]
/// for anyone else — degraded, **never rejected** (CONTEXT §Gateway; the daemon
/// logs the re-stamp loudly). Authority is the resolved `origin`, never the
/// request flag or the payload. A send that does not request `user-msg` is
/// always plain `msg`.
// [impl->REQ-MSG-5]
pub fn gate_user_msg_type(requests_user_msg: bool, origin: &MsgOrigin) -> &'static str {
    if requests_user_msg && is_user_backed_origin(origin) {
        EVENT_TYPE_USER_MSG
    } else {
        EVENT_TYPE_MSG
    }
}

// ---------------------------------------------------------------------------
// Compose (verbatim format — sister `assemble_event_line` branches)
// ---------------------------------------------------------------------------

/// Compose a regular message envelope: `<EVENT type="msg" from="F">B</EVENT>`.
/// `from` is attr-escaped; `body` is body-escaped (`\n`→`<br>`). Mirrors the
/// sister's regular-message branch (`poll.rs` `assemble_event_line`).
// [impl->REQ-ARCH-4]
pub fn compose_msg_event(from: &str, body: &str) -> String {
    format!(
        "<EVENT type=\"msg\" from=\"{}\">{}</EVENT>",
        event_attr_escape(from),
        event_body_escape(body)
    )
}

/// Compose a `user-msg` envelope:
/// `<EVENT type="user-msg" from="F">B</EVENT>` — the user-authority sibling of
/// [`compose_msg_event`]. The caller is responsible for having passed the
/// identity gate ([`gate_user_msg_type`]); this only renders the wire line.
// [impl->REQ-MSG-5]
pub fn compose_user_msg_event(from: &str, body: &str) -> String {
    compose_typed_event(EVENT_TYPE_USER_MSG, &[("from", from)], body)
}

/// Compose an arbitrary typed envelope: `<EVENT type="T" k="v" …>body</EVENT>`.
/// `event_type` and every attr value are attr-escaped; `body` is body-escaped.
/// Attr order is preserved (type first, then the supplied pairs). This is the
/// general composer the payload layer (typed commands / blobs) builds on;
/// [`compose_msg_event`] is the `type="msg"` special case.
// [impl->REQ-ARCH-4]
pub fn compose_typed_event(event_type: &str, attrs: &[(&str, &str)], body: &str) -> String {
    let mut s = format!("<EVENT type=\"{}\"", event_attr_escape(event_type));
    for (k, v) in attrs {
        s.push(' ');
        s.push_str(k);
        s.push_str("=\"");
        s.push_str(&event_attr_escape(v));
        s.push('"');
    }
    s.push('>');
    s.push_str(&event_body_escape(body));
    s.push_str("</EVENT>");
    s
}

/// Compose a self-originated alarm envelope:
/// `<EVENT type="alarm" target-time="T" current-time="C">B</EVENT>`.
/// ISO-8601 times are attr-escaped (line-safe by construction); `body` is
/// body-escaped. Mirrors the sister's alarm branch.
// [impl->REQ-ARCH-4]


Identity-gate core is well-anchored: `MsgOrigin` is built from transport-proven identity (QUIC node id → registry), forged `from` is inert, re-stamp-down degrades never rejects, Gateway via open tag. The load-bearing claim is that the WAN path constructs `MsgOrigin` from the QUIC-proven node id, not wire bytes. Verifying that + the truth-table/N-1 tests.