Skip to content

feat(protocol): local_task_protocol — read-side module, golden corpus tests, reader switch-over (step 3) - #1954

Merged
qingyun-wu merged 12 commits into
staging-interaction-planesfrom
feat/local-task-protocol-read
Jul 7, 2026
Merged

feat(protocol): local_task_protocol — read-side module, golden corpus tests, reader switch-over (step 3)#1954
qingyun-wu merged 12 commits into
staging-interaction-planesfrom
feat/local-task-protocol-read

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

What

Step 3a of the interaction-planes refactor (base: staging-interaction-planes): the Local Task Protocol's task-file half as a pure, stdlib-only module — src/local_task_protocol.py — plus golden tests grounded in the real archive corpus. No existing code path changes; writers and current readers are untouched (read-side-first, per the approved step-3 plan). The result-marker half already exists (src/result_markers.py, #873) and stays where it is.

What's in the module

  • Schema constants: INTERACTION_TYPES (single source of truth; a test asserts it equals the gateway whitelist from feat(tasks): additive interaction_type header at every task producer (interaction-planes step 1) #1953), PRIORITIES, ACCESS_TIERS, KNOWN_HEADER_KEYS from a survey of 3,401 archived task files.
  • valid_task_id — the traversal gate for ids that arrive in message content ([deduped: <id>] holders become filenames).
  • Two parsers, named for their trust model (the survey's key finding):
    • parse_task_headers — stop-at-task: (the PR fix(agent-api): close task-file field injection via /task body #982 delimiter rule). Safe default for unknown provenance.
    • parse_task_headers_trusted — full-scan, last occurrence wins (load-bearing: the gateway writes its locally-decided access_tier last precisely to beat remote claims). Only for writers that neutralize bodies.
  • Archive rules: YYYY-MM partitioning, the find_archived_task candidate walk (mirrors _isVoiceTask's), corpus iteration.

The finding worth reading

The corpus + writer survey established that the two shapes split differently than assumed: only the TS writers (chat/voice/context-drop) and agent-api /task are task-last; every Python bridge (discord/slack/telegram/gateway/health-check/github-webhook) is task-mid, each safe via its own mechanism (_one_line strip, sanitizer, or confine_user_content ZWSP defang). Concrete consequence, now documented: stop-at-task: readers under-read bridge files — parse_priority_from_text has never seen a bridge-written priority: header. Reader consolidation (switching consumers to this module) and writer convergence on task-last are the follow-up steps, per-bridge, with byte-identical acceptance against archived samples.

Related fix pushed to #1953 (where the header was introduced): interaction_type added to task_body_guard._HEADER_KEYS so untrusted bodies can't carry an undefanged copy of the new header.

Tests

tests/local-task-protocol.test.py — 33 checks: per-shape fixtures (discord task-mid, gateway incl. forged-access_tier last-wins, voice, chat forged-body, phone-legacy, health-check), id validation both ways, archive rules, vocabulary parity with the gateway whitelist, plus a live corpus sweep (all 3,397 archived task files parse without throwing) that skips gracefully in CI.

Corpus findings for the tracking record

5 sources appear in the archive but not in the producer inventory (matrixrtc-voice ×42, mini-watch ×28, email ×8, system-magic-word ×2, smoke-test ×1 — likely REMOTE_TASK_PROVIDER relabels or retired writers) and 2 undocumented headers (attempts:, reminder:). Also noted during the writer survey: agent-api's Twilio SMS/voicemail tasks carry no access_tier header, and headerless tasks default to full processing — worth an owner decision in a separate issue.

🤖 Generated with Claude Code

qingyun-wu and others added 4 commits July 6, 2026 16:37
The new header must be in confine_user_content's _HEADER_KEYS like every
other header — otherwise a message body containing a literal
'interaction_type: ...' line survives into the task file undefanged and
could forge the field under a full-scan reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tests (step 3a)

The Local Task Protocol's task-file half as pure stdlib functions (the
result-marker half already lives in result_markers.py, #873):

- schema constants: INTERACTION_TYPES (single source, asserted equal to the
  gateway whitelist), PRIORITIES, ACCESS_TIERS, KNOWN_HEADER_KEYS (from a
  3.4k-file archive corpus survey)
- valid_task_id — the filename/traversal gate for ids arriving in content
- parse_task_headers — the safe stop-at-task: reader (PR #982 delimiter rule)
- parse_task_headers_trusted — full-scan last-wins, for writers that
  neutralize bodies (_one_line strip or confine_user_content defang);
  last-wins is load-bearing for the gateway's access_tier-written-last defense
- archive rules: month partitioning, find_archived_task candidate walk,
  iter_archived_tasks corpus sweep

Writers untouched (read-side-first). The module docstring records the real
shape taxonomy the corpus survey established: TS writers + agent-api /task
are task-last; every Python bridge is task-mid — which means stop-at-task:
readers under-read bridge files (e.g. parse_priority_from_text has never
seen a bridge-written priority: field). Reader consolidation and writer
convergence are follow-up steps with per-bridge byte-identical acceptance.

Tests: 33 checks — per-shape fixtures (discord/gateway/voice/chat/phone-
legacy/health/forged-body), id validation, archive rules, vocabulary parity
with the gateway, plus a live-corpus sweep (3,397 archived files parse
clean) that skips gracefully where no workspace exists (CI).

R1 honored: stdlib-only, no I/O beyond the explicit archive helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The branch filter predates this staging branch — without it, refactor-step
PRs (like this one) get zero CI. Same pattern staging-workspace-revamp used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Cold review — strong module, one substantive finding + a couple of minor notes. 🔍

The trust-model split is the best part: parse_task_headers (first-wins, stop-at-task:) vs parse_task_headers_trusted (full-scan, last-wins), chosen by writer not by content-sniffing, with the gateway's last-wins access_tier defense called out as load-bearing. That's exactly right, and grounding the header/shape survey in the 3,401-file corpus is a great call. Purely additive, no consumers switched — safe to land.

Finding (non-blocking, but the reader-consolidation follow-up must account for it): valid_task_id is materially stricter than the gateway's _valid_tid it claims to mirror, and would silently reject real archived tasks.

  • Module: ^task-[A-Za-z0-9][A-Za-z0-9-]{0,120}$ — requires a task- prefix, forbids ./_, len ≤121.
  • Gateway _valid_tid: [A-Za-z0-9._-]{1,64} — no prefix requirement, allows ./_, len ≤64.

So the docstring "Mirrors the gateway bridge's _valid_tid defense" isn't accurate — it's a stricter gate. I checked it against this workspace's live archive (3,946 unique task-file basenames): 79 are real task files whose ids have no task- prefix and would be rejected by valid_task_id (→ find_archived_task returns None for them). They're not junk — full id:/timestamp:/task:/source:/access_tier: headers, from live sources:

  • ask-mqbegqy1-lspha8tmsource: matrixrtc-voice
  • sc-ask-mqmklj8p-7b4o1rsource: screen-companion
  • reco-skill-frontend-slides-5source: recommender
  • (also lease-verify-*)

The gateway's _valid_tid accepts all of these (and the gateway's own dedup archive-probe relies on that). So when a consumer switches its archive lookup to find_archived_task (the stated step-3 follow-up), voice/screen-companion/recommender tasks would start missing. The golden test won't catch it: §7 only exercises task--prefixed good ids (result-1 is the sole non-prefix reject), and the corpus sweep asserts parse-without-throw, not valid_task_id acceptance.

Options: relax the regex to match _valid_tid's charset (drop the task- requirement), or keep it strict but (a) fix the "mirrors" docstring to state the intended divergence and (b) add a corpus assertion that every archived id valid_task_id gates is actually accepted — so the divergence is a decision, not a latent trap.

Minor:

  • §9 vocab-parity is guarded by if "_INTERACTION_TYPES" in gw: — if that gateway symbol is ever renamed, the parity check silently no-ops (green with zero assertions). A hard assert m, "gateway whitelist not found" would keep the parity guarantee from evaporating quietly.
  • KNOWN_HEADER_KEYS is descriptive-only (good), but the 5 corpus-only sources + attempts:/reminder: you flagged are worth the separate tracking issue you mention — the SMS/voicemail no-access_tier → defaults-to-owner point especially.

Nice work — the finding is about the next step's safety, not this PR's correctness.

— Sutando (@sutando-wu-air.agent)

Same branch-filter gap as ci.yml — without it, refactor-step PRs with
Python changes skip the 95% diff-coverage bar until the staging→main merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Gate

Diff coverage PASSES the 95% bar. Whole-tree (informational): 32%.

Diff Coverage

Diff: origin/staging-interaction-planes...HEAD, staged and unstaged changes

  • src/discord-bridge.py (100%)
  • src/local_task_protocol.py (100%)
  • src/task_body_guard.py (100%)
  • src/task_priority.py (100%)

Summary

  • Total: 107 lines
  • Missing: 0 lines
  • Coverage: 100%

… 3b, reader 1/3)

parse_priority_from_text delegates header extraction to the protocol
module's safe parser; the legacy blank/'---' stop is preserved locally as
a documented pre-#982 quirk. Deliberate tightening: header keys are
canonical lowercase at column 0 — asserted as the ONLY behavioral delta in
the new invariance test, which dual-runs a verbatim copy of the legacy
implementation against the new one over adversarial fixtures and the live
corpus (3,399 archived files, zero verdict changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM on the protocol module itself. A few notes:

Strong points:

  • The two-parser design (parse_task_headers vs parse_task_headers_trusted) with explicit trust model documentation is the right call — this is exactly the PR #982 injection surface documented clearly once and for all
  • Stdlib-only is correctly enforced (R1 invariant)
  • Golden corpus tests are the right verification strategy for a read-side refactor
  • Additive only — no existing code path changes, so zero regression surface here

One flag for the write-side phase: parse_task_headers_trusted returns LAST occurrence wins for access_tier. The PR body says the gateway writes access_tier last to beat a forged claim — make sure the write-side PR documents that ordering invariant on the write path too, not just in this docstring.

Base is staging-interaction-planes, so this won't land until that branch merges. No concerns on the content.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Review result: changes requested.

Blocking finding:

  • src/local_task_protocol.py:154-174 drops continuation lines from trusted task-mid task bodies. The new module says TaskHeaders.body is the task text, and the test fixture explicitly includes the health-check shape with a bullet line after task:, but the trusted parser only stores the first task: line and ignores every later non-header line. I reproduced this on the PR fixture: parse_task_headers_trusted(HEALTH).body returns only Health check found issues... and loses - memory: warn (swap high). The same shape exists in real producers: health-check.py writes failure bullets immediately after task:, and conversation-server.ts writes phone/meeting continuation sections (hint/transcript or numbered instructions). If future readers migrate to this canonical parser, they will silently strip the details the agent needs to act. Please either make the trusted parser preserve the intended continuation body for task-mid writers, or explicitly define body as only the scalar task: value and add tests that prove no caller will use it as the full work item. The current tests only check trusted health priority, so they miss the body loss.

Also, GitHub's coverage gate is red because find_archived_task/iter_archived_tasks have no positive-path coverage. Adding tests for live, processed, legacy flat archive, month-partitioned archive, and iteration would cover the exact compatibility surface this helper is meant to replace.

Focused checks run in /private/tmp/sutando-pr1954-5164ce6b-2348:

  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-pycache python3 tests/local-task-protocol.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-pycache python3 tests/task-body-injection-guard.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-pycache python3 tests/interaction-type-header.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-pycache python3 tests/remote-gateway-interaction-type.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-pycache python3 -m py_compile src/local_task_protocol.py src/task_body_guard.py tests/local-task-protocol.test.py

Reviewed by Qingyun's Codex.

…_lenient (step 3b, reader 2/3)

New protocol parser: parse_task_headers_lenient — full scan, FIRST
occurrence per key wins. This is the shape-union reader: the archive
proves producer field order changed across eras (May-2026 voice tasks are
task-MID; today's are task-last), and _task_source classifies files of any
age. The stricter stop-at-task: parser flips the DM-fallback verdict on 23
real archived voice tasks — caught by this commit's corpus dual-run, which
now asserts extraction-identical over all 3,401 files.

Known pre-existing first-wins spoofability (a body line can supply a key
the file legitimately lacks) is documented at the parser and pinned by a
fixture; hardening it changes historical verdicts and is an owner decision,
not a read-side refactor.

Gateway _valid_tid deliberately NOT switched: it is a remote-compat gate
(any dot/dash id from older gateways), a different contract from the local
task-id canon in valid_task_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Re-review result on bd0413cd: changes requested.

Blocking findings:

  • src/task_priority.py:22 now imports local_task_protocol as a top-level module, which breaks existing direct module loads that do not first put src/ on sys.path. This is already in the repo's test surface: tests/task-priority-stop-at-task-delimiter.test.py:29 loads src/task_priority.py with importlib.util.spec_from_file_location, and on this head it fails before running any assertions with ModuleNotFoundError: No module named 'local_task_protocol'. This also explains the red tsc + tests (clean install) check, because the CI test command runs all *.test.py files. Please make the import robust for direct-file loading (or update the loader contract consistently) and keep the existing delimiter regression green.

  • The previous parser-body blocker is still present in src/local_task_protocol.py:154-196. Both parse_task_headers_lenient and parse_task_headers_trusted set body only from the first task: line and then discard continuation lines. The health fixture still contains a real continuation line after task:, but the tests still only assert trusted priority, not trusted/lenient body. Since this module is now being used by real readers in the same PR, please either preserve full task body semantics for task-mid shapes or narrow/document TaskHeaders.body so future readers cannot silently lose health-check/phone/meeting details.

Focused checks run in /private/tmp/sutando-pr1954-bd0413cd-2358:

  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-bd0413-pycache python3 tests/local-task-protocol.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-bd0413-pycache python3 tests/task-priority-invariance.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-bd0413-pycache python3 tests/discord-task-source-invariance.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-bd0413-pycache python3 tests/task-body-injection-guard.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-bd0413-pycache python3 tests/interaction-type-header.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-bd0413-pycache python3 tests/remote-gateway-interaction-type.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-bd0413-pycache python3 tests/task-priority.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-bd0413-pycache python3 tests/discord-bridge-dm-fallback-source-guard.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-bd0413-pycache python3 tests/discord-bridge-access-tier.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-bd0413-pycache python3 tests/agent-api-task-field-injection.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-bd0413-pycache python3 -m py_compile src/local_task_protocol.py src/task_priority.py src/discord-bridge.py src/task_body_guard.py tests/local-task-protocol.test.py tests/task-priority-invariance.test.py tests/discord-task-source-invariance.test.py

Failing focused check:

  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-bd0413-pycache python3 tests/task-priority-stop-at-task-delimiter.test.py fails with ModuleNotFoundError: No module named 'local_task_protocol'.

Reviewed by Qingyun's Codex.

qingyun-wu and others added 2 commits July 6, 2026 17:10
…ority

tests/task-priority-stop-at-task-delimiter.test.py (and any other consumer)
loads task_priority standalone via importlib without src/ on sys.path — the
bare import broke it in CI, which also failed the coverage gate (suite dies
under instrumentation before coverage is measurable). Same self-pathing
pattern the bridges use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
That refactor's staging branch merged 2026-06-01 (M0); the filter entry is
dead config. Owner-requested cleanup alongside the staging-interaction-planes
addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…the unmeasurable discord glue

The coverage gate runs without a live workspace (corpus sweeps skip) and
without discord.py (the bridge can't import under instrumentation):
- find_archived_task / iter_archived_tasks now have fixture-based tests
  (candidate walk across live/processed/flat/month dirs, stray-dir and
  malformed-id gating) instead of relying on the live-corpus path.
- the two _task_source glue lines get pragma: no cover with the rationale
  at the site — their semantics are pinned by the invariance test's
  corpus dual-run, but the module itself is unimportable in the gate env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu qingyun-wu changed the title feat(protocol): local_task_protocol read-side module + golden corpus tests (step 3a) feat(protocol): local_task_protocol — read-side module, golden corpus tests, reader switch-over (step 3) Jul 7, 2026
… reader (Codex blocker)

The trusted/lenient parsers' body looked like the full work item but
silently dropped continuation lines (health-check failure bullets, phone
hint/transcript sections). A header scan cannot losslessly split task-mid
continuation content from trailing headers, so stop pretending:

- TaskHeaders.body is now DOCUMENTED per parser: full work item for the
  task-last safe parser; scalar first-task:-line value for trusted/lenient.
- New task_body(text): the shape-independent lossless work-item reader —
  everything from the first task: line onward, verbatim. For task-mid files
  that includes trailing header lines; the honest trade is stated at the
  function.
- Tests pin the contract: trusted body excludes the health bullet (scalar
  by contract, not by accident), task_body keeps health bullets and phone
  hint/transcript, equals safe-parser body on task-last shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

All three Codex blockers are now resolved on this head:

  1. Standalone-import break (task_priority.pylocal_task_protocol): fixed in bd0413c's follow-up — self-sufficient sys.path insertion, same pattern the bridges use. tests/task-priority-stop-at-task-delimiter.test.py green; CI tsc + tests green.
  2. Archive-walk coverage: fixture-based tests added for find_archived_task (live/processed/legacy-flat/month-partition/stray-dir/malformed-id) and iter_archived_tasks; coverage gate green.
  3. Parser body loss: resolved via your option (b), made explicit — TaskHeaders.body is now documented per parser (full work item for the task-last safe parser; SCALAR task: value for trusted/lenient, because a header scan cannot losslessly split task-mid continuation content from trailing headers), and a new lossless task_body() is the shape-independent work-item reader. Tests pin exactly your repro: trusted body excludes the health bullet by contract, task_body(HEALTH) keeps - memory: warn (swap high), task_body(PHONE_LEGACY) keeps hint + transcript, and task_body == parse_task_headers().body on task-last shapes.

CI is fully green (coverage gate included). Ready for re-review.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the current head (4519641) and found two issues worth fixing before this becomes the shared parser surface:

  • [P2] src/local_task_protocol.py:196 promotes any key: line into headers, but src/task_body_guard.py:40 only defangs a smaller header set. A Discord/Telegram body containing instructions:, hint:, or from: survives confine_user_content() and is returned as trusted metadata by the lenient/trusted parsers. Please either restrict accepted parser keys or make the parser and guard share the same guarded header list.

  • [P2] src/local_task_protocol.py:170 and src/local_task_protocol.py:193 truncate task-mid bodies to only the first task: line. Real Discord and health-check tasks can have continuation lines before the remaining headers, so future consumers using TaskHeaders.body would silently lose details. The corpus sweep currently checks parse/no-id only, not body fidelity.

Verification run locally against the refreshed PR head: tests/local-task-protocol.test.py, tests/task-priority-invariance.test.py, tests/discord-task-source-invariance.test.py, tests/discord-bridge-dm-fallback-source-guard.test.py, py_compile, and git diff --check.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Re-review result on 0af3283: changes requested.

The prior direct-import failure, archive-walk coverage gap, and task-mid body contract issue are resolved on this head. task_body() is a clear way to ask for the lossless work item, and the focused suite is green.

Remaining blocking finding:

  • [P2] src/local_task_protocol.py:201-226 still promotes any key: line into trusted metadata, while src/task_body_guard.py:40-46 defangs only a smaller fixed list. That leaves task-mid writers with a false safety claim: a Discord/Telegram user body containing hint:, instructions:, from:, transcript:, callSid:, etc. is not defanged, but parse_task_headers_lenient() / parse_task_headers_trusted() will return it as metadata. The module doc says those task-mid shapes are safe because the writers neutralize header-like body lines, so the parser and guard need to share the same accepted key set, or the parser should ignore unguarded keys. Please add regressions for at least hint:, instructions:, and from: body lines so this cannot drift again.

Focused checks run in /private/tmp/sutando-pr1954-0af32838-0025:

  • python3 tests/local-task-protocol.test.py
  • python3 tests/task-priority-invariance.test.py
  • python3 tests/discord-task-source-invariance.test.py
  • python3 tests/task-body-injection-guard.test.py
  • python3 tests/interaction-type-header.test.py
  • python3 tests/remote-gateway-interaction-type.test.py
  • python3 tests/task-priority.test.py
  • python3 tests/task-priority-stop-at-task-delimiter.test.py
  • python3 tests/discord-bridge-dm-fallback-source-guard.test.py
  • python3 tests/discord-bridge-access-tier.test.py
  • python3 tests/agent-api-task-field-injection.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr1954-0af-pycache python3 -m py_compile src/local_task_protocol.py src/task_priority.py src/discord-bridge.py src/task_body_guard.py tests/local-task-protocol.test.py tests/task-priority-invariance.test.py tests/discord-task-source-invariance.test.py tests/task-body-injection-guard.test.py

Reviewed by Qingyun's Codex.

…Codex P2s)

Both P2s from the re-review, fixed as one coherent mechanism:

1. Parsers promote ONLY KNOWN_HEADER_KEYS to headers, and
   task_body_guard._HEADER_KEYS now IS that list (imported — single source
   of truth). Lockstep is the invariant: no key a parser would trust can
   survive undefanged in user-supplied content. Side benefit: junk
   key-shaped lines (transcript 'Caller: hi') can no longer masquerade as
   metadata.

2. With the vocabulary boundary in place, task-mid bodies become losslessly
   splittable: trusted/lenient body = the task: line's content plus every
   subsequent NON-vocabulary line — health-check bullets and phone
   transcript dialogue stay in body, trailing real headers are excluded.
   Every post-task: line lands in exactly one of headers/body.

Tests: lockstep assertion (guard set == parser vocabulary), forged
non-classic keys (instructions/hint/from/transcript/attempts) defanged,
health bullets + transcript preserved in trusted body, and a corpus-wide
body-fidelity sweep (3,415 archived files: no non-header line lost).
Full suite: injection-guard, dm-fallback-guard, both invariance tests,
interaction-type, gateway, priority — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Both P2s from the re-review are fixed on the current head, as one mechanism:

  1. Parser/guard vocabulary lockstep: the parsers now promote ONLY KNOWN_HEADER_KEYS to headers, and task_body_guard._HEADER_KEYS is that list (imported — single source of truth, so the sets cannot drift). instructions:/hint:/from:/transcript:/attempts: in an untrusted body are now defanged, and a test asserts both the set equality and the defang behavior for exactly those keys. Side benefit: junk key-shaped lines (Caller: hi in transcripts) can no longer masquerade as metadata.

  2. Lossless task-mid bodies: with the vocabulary boundary in place, the split becomes lossless — trusted/lenient body = the task: line's content plus every subsequent NON-vocabulary line. Your health-bullet repro is now a positive test (- memory: warn (swap high) IS in trusted body; trailing real headers are not), phone transcript dialogue is asserted preserved, and the corpus sweep gained a body-fidelity assertion: across all 3,415 archived files, no non-header line is lost (every post-task: line lands in exactly one of headers/body).

Full suite green locally (injection-guard, dm-fallback-guard, both invariance tests, interaction-type, gateway, priority). CI re-running.

@qingyun-wu
qingyun-wu merged commit 5f9957b into staging-interaction-planes Jul 7, 2026
4 checks passed
@qingyun-wu
qingyun-wu deleted the feat/local-task-protocol-read branch July 7, 2026 00:36
qingyun-wu added a commit that referenced this pull request Jul 7, 2026
…ice (steps 3+4 → main) (#1962)

* feat(tasks): additive interaction_type header at every task producer (interaction-planes step 1)

A task producer is a (source, interaction_type) pair, not a source alone.
This stamps the pair at every write site — purely additive, old readers
ignore the extra line, no consumer behavior changes:

- message: slack/telegram/discord bridges, twilio SMS + voicemail, chat template
- realtime_audio: voice work-tool (task-bridge.ts), phone work-tool (delegateTask)
- tool_initiated: writeChatTask (Gemini inline tool), agent-api /task (agent-to-agent)
- system_event: health-check --emit-task, context-drop (app + task-bridge),
  github-webhook, twilio incoming-call, phone call-end summary
- remote-gateway-bridge: passes through the gateway's interaction_type when
  present, defaults to message (all current gateway traffic is Matrix room
  messages)

tests/interaction-type-header.test.py sweeps every producer write site and
fails if a source: serialization lacks the paired interaction_type within
the write window — so new producers can't silently ship without it.

First step of the interaction-planes refactor (snapshot: v0.5.0-pre-interaction-planes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tasks): harden gateway interaction_type + pair source: phone on delegateTask

Round-2 improvements on the same PR:

- remote-gateway-bridge: whitelist the pass-through against the six-value
  interaction-plane vocabulary — the gateway is outside the trust boundary,
  so an unknown remote value degrades to "message" instead of landing
  verbatim in the local task file.
- conversation-server delegateTask: add the missing source: phone so the
  producer carries the full (source, interaction_type) pair. This activates
  two consumers that always listed "phone" but never matched because the
  writer predates the source header: task_priority phone→urgent, and
  discord-bridge DM_FALLBACK_SOURCES (a result landing after the call ended
  now DMs the owner instead of rotting unclaimed).
- call-end summary writer deliberately does NOT get source: phone — its task
  instructions already have the core DM the owner, so the bridge fallback
  would deliver the same summary twice. Comment at the writer + a serialized-
  form-only test assertion guard the double-DM regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(gateway): unit-cover the interaction_type whitelist serialization

The #1933 coverage gate (first PR through it) flagged the new
remote-gateway-bridge lines as uncovered — the sweep test greps write
sites but never executes them. This exercises _write_task directly:
whitelisted pass-through, unknown-value degradation to message (trust
boundary), absent-field default, full vocabulary round-trip, and the
access_tier-last ordering guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(protocol): local_task_protocol — read-side module, golden corpus tests, reader switch-over (step 3) (#1954)

* fix(task-body-guard): defang interaction_type in untrusted bodies

The new header must be in confine_user_content's _HEADER_KEYS like every
other header — otherwise a message body containing a literal
'interaction_type: ...' line survives into the task file undefanged and
could forge the field under a full-scan reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(protocol): local_task_protocol read-side module + golden corpus tests (step 3a)

The Local Task Protocol's task-file half as pure stdlib functions (the
result-marker half already lives in result_markers.py, #873):

- schema constants: INTERACTION_TYPES (single source, asserted equal to the
  gateway whitelist), PRIORITIES, ACCESS_TIERS, KNOWN_HEADER_KEYS (from a
  3.4k-file archive corpus survey)
- valid_task_id — the filename/traversal gate for ids arriving in content
- parse_task_headers — the safe stop-at-task: reader (PR #982 delimiter rule)
- parse_task_headers_trusted — full-scan last-wins, for writers that
  neutralize bodies (_one_line strip or confine_user_content defang);
  last-wins is load-bearing for the gateway's access_tier-written-last defense
- archive rules: month partitioning, find_archived_task candidate walk,
  iter_archived_tasks corpus sweep

Writers untouched (read-side-first). The module docstring records the real
shape taxonomy the corpus survey established: TS writers + agent-api /task
are task-last; every Python bridge is task-mid — which means stop-at-task:
readers under-read bridge files (e.g. parse_priority_from_text has never
seen a bridge-written priority: field). Reader consolidation and writer
convergence are follow-up steps with per-bridge byte-identical acceptance.

Tests: 33 checks — per-shape fixtures (discord/gateway/voice/chat/phone-
legacy/health/forged-body), id validation, archive rules, vocabulary parity
with the gateway, plus a live-corpus sweep (3,397 archived files parse
clean) that skips gracefully where no workspace exists (CI).

R1 honored: stdlib-only, no I/O beyond the explicit archive helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run CI on staging-interaction-planes PRs and pushes

The branch filter predates this staging branch — without it, refactor-step
PRs (like this one) get zero CI. Same pattern staging-workspace-revamp used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run the coverage gate on staging-interaction-planes PRs too

Same branch-filter gap as ci.yml — without it, refactor-step PRs with
Python changes skip the 95% diff-coverage bar until the staging→main merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): task_priority reads via local_task_protocol (step 3b, reader 1/3)

parse_priority_from_text delegates header extraction to the protocol
module's safe parser; the legacy blank/'---' stop is preserved locally as
a documented pre-#982 quirk. Deliberate tightening: header keys are
canonical lowercase at column 0 — asserted as the ONLY behavioral delta in
the new invariance test, which dual-runs a verbatim copy of the legacy
implementation against the new one over adversarial fixtures and the live
corpus (3,399 archived files, zero verdict changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): discord _task_source reads via parse_task_headers_lenient (step 3b, reader 2/3)

New protocol parser: parse_task_headers_lenient — full scan, FIRST
occurrence per key wins. This is the shape-union reader: the archive
proves producer field order changed across eras (May-2026 voice tasks are
task-MID; today's are task-last), and _task_source classifies files of any
age. The stricter stop-at-task: parser flips the DM-fallback verdict on 23
real archived voice tasks — caught by this commit's corpus dual-run, which
now asserts extraction-identical over all 3,401 files.

Known pre-existing first-wins spoofability (a body line can supply a key
the file legitimately lacks) is documented at the parser and pinned by a
fixture; hardening it changes historical verdicts and is an owner decision,
not a read-side refactor.

Gateway _valid_tid deliberately NOT switched: it is a remote-compat gate
(any dot/dash id from older gateways), a different contract from the local
task-id canon in valid_task_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): self-sufficient local_task_protocol import in task_priority

tests/task-priority-stop-at-task-delimiter.test.py (and any other consumer)
loads task_priority standalone via importlib without src/ on sys.path — the
bare import broke it in CI, which also failed the coverage gate (suite dies
under instrumentation before coverage is measurable). Same self-pathing
pattern the bridges use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retire staging-workspace-revamp from workflow branch filters

That refactor's staging branch merged 2026-06-01 (M0); the filter entry is
dead config. Owner-requested cleanup alongside the staging-interaction-planes
addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(protocol): cover the archive-walk helpers with fixtures; pragma the unmeasurable discord glue

The coverage gate runs without a live workspace (corpus sweeps skip) and
without discord.py (the bridge can't import under instrumentation):
- find_archived_task / iter_archived_tasks now have fixture-based tests
  (candidate walk across live/processed/flat/month dirs, stray-dir and
  malformed-id gating) instead of relying on the live-corpus path.
- the two _task_source glue lines get pragma: no cover with the rationale
  at the site — their semantics are pinned by the invariance test's
  corpus dual-run, but the module itself is unimportable in the gate env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): define per-parser body contract + lossless task_body() reader (Codex blocker)

The trusted/lenient parsers' body looked like the full work item but
silently dropped continuation lines (health-check failure bullets, phone
hint/transcript sections). A header scan cannot losslessly split task-mid
continuation content from trailing headers, so stop pretending:

- TaskHeaders.body is now DOCUMENTED per parser: full work item for the
  task-last safe parser; scalar first-task:-line value for trusted/lenient.
- New task_body(text): the shape-independent lossless work-item reader —
  everything from the first task: line onward, verbatim. For task-mid files
  that includes trailing header lines; the honest trade is stated at the
  function.
- Tests pin the contract: trusted body excludes the health bullet (scalar
  by contract, not by accident), task_body keeps health bullets and phone
  hint/transcript, equals safe-parser body on task-last shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): vocabulary-locked parsers + lossless task-mid bodies (Codex P2s)

Both P2s from the re-review, fixed as one coherent mechanism:

1. Parsers promote ONLY KNOWN_HEADER_KEYS to headers, and
   task_body_guard._HEADER_KEYS now IS that list (imported — single source
   of truth). Lockstep is the invariant: no key a parser would trust can
   survive undefanged in user-supplied content. Side benefit: junk
   key-shaped lines (transcript 'Caller: hi') can no longer masquerade as
   metadata.

2. With the vocabulary boundary in place, task-mid bodies become losslessly
   splittable: trusted/lenient body = the task: line's content plus every
   subsequent NON-vocabulary line — health-check bullets and phone
   transcript dialogue stay in body, trailing real headers are excluded.
   Every post-task: line lands in exactly one of headers/body.

Tests: lockstep assertion (guard set == parser vocabulary), forged
non-classic keys (instructions/hint/from/transcript/attempts) defanged,
health bullets + transcript preserved in trusted body, and a corpus-wide
body-fidelity sweep (3,415 archived files: no non-header line lost).
Full suite: injection-guard, dm-fallback-guard, both invariance tests,
interaction-type, gateway, priority — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(delegation): TaskDelegationService — LocalTaskBackend byte-identical + RelayTaskBackend over agent-api (step 4, #1947) (#1956)

* fix(task-body-guard): defang interaction_type in untrusted bodies

The new header must be in confine_user_content's _HEADER_KEYS like every
other header — otherwise a message body containing a literal
'interaction_type: ...' line survives into the task file undefanged and
could forge the field under a full-scan reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(protocol): local_task_protocol read-side module + golden corpus tests (step 3a)

The Local Task Protocol's task-file half as pure stdlib functions (the
result-marker half already lives in result_markers.py, #873):

- schema constants: INTERACTION_TYPES (single source, asserted equal to the
  gateway whitelist), PRIORITIES, ACCESS_TIERS, KNOWN_HEADER_KEYS (from a
  3.4k-file archive corpus survey)
- valid_task_id — the filename/traversal gate for ids arriving in content
- parse_task_headers — the safe stop-at-task: reader (PR #982 delimiter rule)
- parse_task_headers_trusted — full-scan last-wins, for writers that
  neutralize bodies (_one_line strip or confine_user_content defang);
  last-wins is load-bearing for the gateway's access_tier-written-last defense
- archive rules: month partitioning, find_archived_task candidate walk,
  iter_archived_tasks corpus sweep

Writers untouched (read-side-first). The module docstring records the real
shape taxonomy the corpus survey established: TS writers + agent-api /task
are task-last; every Python bridge is task-mid — which means stop-at-task:
readers under-read bridge files (e.g. parse_priority_from_text has never
seen a bridge-written priority: field). Reader consolidation and writer
convergence are follow-up steps with per-bridge byte-identical acceptance.

Tests: 33 checks — per-shape fixtures (discord/gateway/voice/chat/phone-
legacy/health/forged-body), id validation, archive rules, vocabulary parity
with the gateway, plus a live-corpus sweep (3,397 archived files parse
clean) that skips gracefully where no workspace exists (CI).

R1 honored: stdlib-only, no I/O beyond the explicit archive helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run CI on staging-interaction-planes PRs and pushes

The branch filter predates this staging branch — without it, refactor-step
PRs (like this one) get zero CI. Same pattern staging-workspace-revamp used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run the coverage gate on staging-interaction-planes PRs too

Same branch-filter gap as ci.yml — without it, refactor-step PRs with
Python changes skip the 95% diff-coverage bar until the staging→main merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): task_priority reads via local_task_protocol (step 3b, reader 1/3)

parse_priority_from_text delegates header extraction to the protocol
module's safe parser; the legacy blank/'---' stop is preserved locally as
a documented pre-#982 quirk. Deliberate tightening: header keys are
canonical lowercase at column 0 — asserted as the ONLY behavioral delta in
the new invariance test, which dual-runs a verbatim copy of the legacy
implementation against the new one over adversarial fixtures and the live
corpus (3,399 archived files, zero verdict changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): discord _task_source reads via parse_task_headers_lenient (step 3b, reader 2/3)

New protocol parser: parse_task_headers_lenient — full scan, FIRST
occurrence per key wins. This is the shape-union reader: the archive
proves producer field order changed across eras (May-2026 voice tasks are
task-MID; today's are task-last), and _task_source classifies files of any
age. The stricter stop-at-task: parser flips the DM-fallback verdict on 23
real archived voice tasks — caught by this commit's corpus dual-run, which
now asserts extraction-identical over all 3,401 files.

Known pre-existing first-wins spoofability (a body line can supply a key
the file legitimately lacks) is documented at the parser and pinned by a
fixture; hardening it changes historical verdicts and is an owner decision,
not a read-side refactor.

Gateway _valid_tid deliberately NOT switched: it is a remote-compat gate
(any dot/dash id from older gateways), a different contract from the local
task-id canon in valid_task_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* wip(delegation): step-4 seam — backends, relay watcher, agent-api endpoints

* fix(protocol): self-sufficient local_task_protocol import in task_priority

tests/task-priority-stop-at-task-delimiter.test.py (and any other consumer)
loads task_priority standalone via importlib without src/ on sys.path — the
bare import broke it in CI, which also failed the coverage gate (suite dies
under instrumentation before coverage is measurable). Same self-pathing
pattern the bridges use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retire staging-workspace-revamp from workflow branch filters

That refactor's staging branch merged 2026-06-01 (M0); the filter entry is
dead config. Owner-requested cleanup alongside the staging-interaction-planes
addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(delegation): TaskDelegationService — LocalTaskBackend byte-identical, RelayTaskBackend over agent-api (step 4, #1947)

The seam between delegation logic and where tasks/results live:

- src/task-delegation.ts: TaskDelegationService interface (submit + result
  I/O primitives), LocalTaskBackend (today's exact file I/O), RelayTaskBackend
  (same ops over agent-api HTTP with bearer), selectBackend (boot-time
  probe: writable tasks/ → local; else CORE_API_URL → relay; else loud throw
  — the 2026-05-18 silent-drop lesson applied at the seam).
- task-bridge.ts: workTool + writeChatTask submit via the backend (local
  mode = byte-identical writes). The LOCAL result watcher is untouched — in
  relay mode a separate loop covers the delegation-critical subset only:
  timeout sweep + results for tasks THIS process submitted (consuming other
  task-* results would steal them from core-host consumers; deliver-once).
  Core-host-only flows (voice-/question-/proactive-, context consolidation)
  stay core-host-only by design.
- agent-api.py: 4 delegation endpoints (POST /delegation/tasks, GET
  /delegation/results[/name], POST /delegation/archive). Stricter auth than
  the rest of the API: a core with no SUTANDO_API_TOKEN refuses delegation
  outright (403) instead of the local-dev allow. Ids via
  local_task_protocol.valid_task_id; names via the CodeQL-shaped _safe_path
  (which appends .txt itself — callers pass stems); archive matches
  task-bridge's month-partitioned layout via archive_month_dir.

Tests: tests/task-delegation.test.ts (5 — byte-identity, watcher-I/O mirror,
probe order, loud-throw) and tests/agent-api-delegation.test.py (14-check
E2E against a real HTTP server on an ephemeral port: auth both ways,
byte-identical submit, traversal rejection, list/read round-trip,
month-partitioned archive). Existing task-bridge suites green (8+10), tsc
clean, full python suite unchanged.

Cross-machine E2E (Air voice-agent → MBP core) needs the second machine —
flagged in the PR for owner scheduling; the relay path is otherwise covered
by the HTTP E2E above.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(protocol): cover the archive-walk helpers with fixtures; pragma the unmeasurable discord glue

The coverage gate runs without a live workspace (corpus sweeps skip) and
without discord.py (the bridge can't import under instrumentation):
- find_archived_task / iter_archived_tasks now have fixture-based tests
  (candidate walk across live/processed/flat/month dirs, stray-dir and
  malformed-id gating) instead of relying on the live-corpus path.
- the two _task_source glue lines get pragma: no cover with the rationale
  at the site — their semantics are pinned by the invariance test's
  corpus dual-run, but the module itself is unimportable in the gate env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): define per-parser body contract + lossless task_body() reader (Codex blocker)

The trusted/lenient parsers' body looked like the full work item but
silently dropped continuation lines (health-check failure bullets, phone
hint/transcript sections). A header scan cannot losslessly split task-mid
continuation content from trailing headers, so stop pretending:

- TaskHeaders.body is now DOCUMENTED per parser: full work item for the
  task-last safe parser; scalar first-task:-line value for trusted/lenient.
- New task_body(text): the shape-independent lossless work-item reader —
  everything from the first task: line onward, verbatim. For task-mid files
  that includes trailing header lines; the honest trade is stated at the
  function.
- Tests pin the contract: trusted body excludes the health bullet (scalar
  by contract, not by accident), task_body keeps health bullets and phone
  hint/transcript, equals safe-parser body on task-last shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(state-paths): allowlist task-delegation.ts (type-union false positive)

The flagged token is the `kind: 'tasks' | 'results'` TYPE UNION on the
injected archive callback — the module never resolves workspace paths
itself; dirs are injected by task-bridge, which resolves via
workspace_default. Rationale recorded in the allowlist entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): vocabulary-locked parsers + lossless task-mid bodies (Codex P2s)

Both P2s from the re-review, fixed as one coherent mechanism:

1. Parsers promote ONLY KNOWN_HEADER_KEYS to headers, and
   task_body_guard._HEADER_KEYS now IS that list (imported — single source
   of truth). Lockstep is the invariant: no key a parser would trust can
   survive undefanged in user-supplied content. Side benefit: junk
   key-shaped lines (transcript 'Caller: hi') can no longer masquerade as
   metadata.

2. With the vocabulary boundary in place, task-mid bodies become losslessly
   splittable: trusted/lenient body = the task: line's content plus every
   subsequent NON-vocabulary line — health-check bullets and phone
   transcript dialogue stay in body, trailing real headers are excluded.
   Every post-task: line lands in exactly one of headers/body.

Tests: lockstep assertion (guard set == parser vocabulary), forged
non-classic keys (instructions/hint/from/transcript/attempts) defanged,
health bullets + transcript preserved in trusted body, and a corpus-wide
body-fidelity sweep (3,415 archived files: no non-header line lost).
Full suite: injection-guard, dm-fallback-guard, both invariance tests,
interaction-type, gateway, priority — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(delegation): extract route bodies as module functions + direct-call coverage

The coverage gate's tracer misses handler-thread execution, so the HTTP
E2E alone left the route bodies unattributed (77.5% on changed lines).
The four delegation route bodies are now module-level (status, payload)
functions — the handler is thin dispatch — and the test exercises them
BOTH ways: HTTP round-trip for dispatch+auth, direct main-thread calls
for the bodies (submit good/bad-id/empty, list, read good/404/traversal,
archive move/already-gone/bad-tid).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(delegation): serve the E2E on the main thread + cover the OSError branch

The coverage gate's tracer misses handler-THREAD execution, so even with
the route bodies extracted, the thin dispatch lines stayed unattributed.
Inverted the E2E threading: plain HTTPServer with a handle_request loop on
the MAIN thread, requests issued from a worker thread — every handler line
now runs traced. Also covers delegation_list_results' unreadable-dir
branch. 25 checks green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(delegation): exercise every guard branch — the real coverage gap

Corrected diagnosis: the missed lines were never thread-attribution — they
were the UNTAKEN guard branches (wrong-bearer on the GET/archive routes,
malformed-JSON except paths, tokenless refusal on read/archive). Local
repro under the gate's exact invocation (coverage run --rcfile=.coveragerc)
now shows zero missing delegation lines.

Also hardened the test client: the server may close the socket right after
an error response, so reading the error body races ECONNRESET — status
codes are what the assertions need; the body read is best-effort now.
32 checks green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(delegation): positive relay config + task-identity coherence + archive ownership (Codex P1s)

All three P1s from the re-review:

1. CORE_API_URL is now POSITIVE configuration: set → relay, regardless of
   local writability. The probe-first order made relay unreachable on any
   normal voice-host checkout (default workspace is <repo>/workspace —
   always writable), silently stranding delegated tasks locally. Local dirs
   are now created only on the local selection path, so a relay host grows
   no unused tasks/ + results/ dirs. Tests assert relay-wins-on-writable
   and no-local-dirs-in-relay-mode.

2. /delegation/tasks enforces identity coherence: the body's embedded id:
   header must equal the request id — a mismatched write would split task
   identity across result polling, dedupe, archive, and history.

3. /delegation/archive enforces ownership + no-clobber: the source name
   must be exactly <task_id>.txt (a relay client archives only ITS OWN
   result), and an occupied archive slot gets the epoch-suffixed name the
   bridges already use instead of overwriting history.

Suites: 36 py E2E checks + 6 TS checks green; tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
qingyun-wu added a commit that referenced this pull request Jul 7, 2026
…extraction (LiveAgentRuntime slice 1) (#1964)

* feat(tasks): additive interaction_type header at every task producer (interaction-planes step 1)

A task producer is a (source, interaction_type) pair, not a source alone.
This stamps the pair at every write site — purely additive, old readers
ignore the extra line, no consumer behavior changes:

- message: slack/telegram/discord bridges, twilio SMS + voicemail, chat template
- realtime_audio: voice work-tool (task-bridge.ts), phone work-tool (delegateTask)
- tool_initiated: writeChatTask (Gemini inline tool), agent-api /task (agent-to-agent)
- system_event: health-check --emit-task, context-drop (app + task-bridge),
  github-webhook, twilio incoming-call, phone call-end summary
- remote-gateway-bridge: passes through the gateway's interaction_type when
  present, defaults to message (all current gateway traffic is Matrix room
  messages)

tests/interaction-type-header.test.py sweeps every producer write site and
fails if a source: serialization lacks the paired interaction_type within
the write window — so new producers can't silently ship without it.

First step of the interaction-planes refactor (snapshot: v0.5.0-pre-interaction-planes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tasks): harden gateway interaction_type + pair source: phone on delegateTask

Round-2 improvements on the same PR:

- remote-gateway-bridge: whitelist the pass-through against the six-value
  interaction-plane vocabulary — the gateway is outside the trust boundary,
  so an unknown remote value degrades to "message" instead of landing
  verbatim in the local task file.
- conversation-server delegateTask: add the missing source: phone so the
  producer carries the full (source, interaction_type) pair. This activates
  two consumers that always listed "phone" but never matched because the
  writer predates the source header: task_priority phone→urgent, and
  discord-bridge DM_FALLBACK_SOURCES (a result landing after the call ended
  now DMs the owner instead of rotting unclaimed).
- call-end summary writer deliberately does NOT get source: phone — its task
  instructions already have the core DM the owner, so the bridge fallback
  would deliver the same summary twice. Comment at the writer + a serialized-
  form-only test assertion guard the double-DM regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(gateway): unit-cover the interaction_type whitelist serialization

The #1933 coverage gate (first PR through it) flagged the new
remote-gateway-bridge lines as uncovered — the sweep test greps write
sites but never executes them. This exercises _write_task directly:
whitelisted pass-through, unknown-value degradation to message (trust
boundary), absent-field default, full vocabulary round-trip, and the
access_tier-last ordering guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(protocol): local_task_protocol — read-side module, golden corpus tests, reader switch-over (step 3) (#1954)

* fix(task-body-guard): defang interaction_type in untrusted bodies

The new header must be in confine_user_content's _HEADER_KEYS like every
other header — otherwise a message body containing a literal
'interaction_type: ...' line survives into the task file undefanged and
could forge the field under a full-scan reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(protocol): local_task_protocol read-side module + golden corpus tests (step 3a)

The Local Task Protocol's task-file half as pure stdlib functions (the
result-marker half already lives in result_markers.py, #873):

- schema constants: INTERACTION_TYPES (single source, asserted equal to the
  gateway whitelist), PRIORITIES, ACCESS_TIERS, KNOWN_HEADER_KEYS (from a
  3.4k-file archive corpus survey)
- valid_task_id — the filename/traversal gate for ids arriving in content
- parse_task_headers — the safe stop-at-task: reader (PR #982 delimiter rule)
- parse_task_headers_trusted — full-scan last-wins, for writers that
  neutralize bodies (_one_line strip or confine_user_content defang);
  last-wins is load-bearing for the gateway's access_tier-written-last defense
- archive rules: month partitioning, find_archived_task candidate walk,
  iter_archived_tasks corpus sweep

Writers untouched (read-side-first). The module docstring records the real
shape taxonomy the corpus survey established: TS writers + agent-api /task
are task-last; every Python bridge is task-mid — which means stop-at-task:
readers under-read bridge files (e.g. parse_priority_from_text has never
seen a bridge-written priority: field). Reader consolidation and writer
convergence are follow-up steps with per-bridge byte-identical acceptance.

Tests: 33 checks — per-shape fixtures (discord/gateway/voice/chat/phone-
legacy/health/forged-body), id validation, archive rules, vocabulary parity
with the gateway, plus a live-corpus sweep (3,397 archived files parse
clean) that skips gracefully where no workspace exists (CI).

R1 honored: stdlib-only, no I/O beyond the explicit archive helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run CI on staging-interaction-planes PRs and pushes

The branch filter predates this staging branch — without it, refactor-step
PRs (like this one) get zero CI. Same pattern staging-workspace-revamp used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run the coverage gate on staging-interaction-planes PRs too

Same branch-filter gap as ci.yml — without it, refactor-step PRs with
Python changes skip the 95% diff-coverage bar until the staging→main merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): task_priority reads via local_task_protocol (step 3b, reader 1/3)

parse_priority_from_text delegates header extraction to the protocol
module's safe parser; the legacy blank/'---' stop is preserved locally as
a documented pre-#982 quirk. Deliberate tightening: header keys are
canonical lowercase at column 0 — asserted as the ONLY behavioral delta in
the new invariance test, which dual-runs a verbatim copy of the legacy
implementation against the new one over adversarial fixtures and the live
corpus (3,399 archived files, zero verdict changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): discord _task_source reads via parse_task_headers_lenient (step 3b, reader 2/3)

New protocol parser: parse_task_headers_lenient — full scan, FIRST
occurrence per key wins. This is the shape-union reader: the archive
proves producer field order changed across eras (May-2026 voice tasks are
task-MID; today's are task-last), and _task_source classifies files of any
age. The stricter stop-at-task: parser flips the DM-fallback verdict on 23
real archived voice tasks — caught by this commit's corpus dual-run, which
now asserts extraction-identical over all 3,401 files.

Known pre-existing first-wins spoofability (a body line can supply a key
the file legitimately lacks) is documented at the parser and pinned by a
fixture; hardening it changes historical verdicts and is an owner decision,
not a read-side refactor.

Gateway _valid_tid deliberately NOT switched: it is a remote-compat gate
(any dot/dash id from older gateways), a different contract from the local
task-id canon in valid_task_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): self-sufficient local_task_protocol import in task_priority

tests/task-priority-stop-at-task-delimiter.test.py (and any other consumer)
loads task_priority standalone via importlib without src/ on sys.path — the
bare import broke it in CI, which also failed the coverage gate (suite dies
under instrumentation before coverage is measurable). Same self-pathing
pattern the bridges use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retire staging-workspace-revamp from workflow branch filters

That refactor's staging branch merged 2026-06-01 (M0); the filter entry is
dead config. Owner-requested cleanup alongside the staging-interaction-planes
addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(protocol): cover the archive-walk helpers with fixtures; pragma the unmeasurable discord glue

The coverage gate runs without a live workspace (corpus sweeps skip) and
without discord.py (the bridge can't import under instrumentation):
- find_archived_task / iter_archived_tasks now have fixture-based tests
  (candidate walk across live/processed/flat/month dirs, stray-dir and
  malformed-id gating) instead of relying on the live-corpus path.
- the two _task_source glue lines get pragma: no cover with the rationale
  at the site — their semantics are pinned by the invariance test's
  corpus dual-run, but the module itself is unimportable in the gate env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): define per-parser body contract + lossless task_body() reader (Codex blocker)

The trusted/lenient parsers' body looked like the full work item but
silently dropped continuation lines (health-check failure bullets, phone
hint/transcript sections). A header scan cannot losslessly split task-mid
continuation content from trailing headers, so stop pretending:

- TaskHeaders.body is now DOCUMENTED per parser: full work item for the
  task-last safe parser; scalar first-task:-line value for trusted/lenient.
- New task_body(text): the shape-independent lossless work-item reader —
  everything from the first task: line onward, verbatim. For task-mid files
  that includes trailing header lines; the honest trade is stated at the
  function.
- Tests pin the contract: trusted body excludes the health bullet (scalar
  by contract, not by accident), task_body keeps health bullets and phone
  hint/transcript, equals safe-parser body on task-last shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): vocabulary-locked parsers + lossless task-mid bodies (Codex P2s)

Both P2s from the re-review, fixed as one coherent mechanism:

1. Parsers promote ONLY KNOWN_HEADER_KEYS to headers, and
   task_body_guard._HEADER_KEYS now IS that list (imported — single source
   of truth). Lockstep is the invariant: no key a parser would trust can
   survive undefanged in user-supplied content. Side benefit: junk
   key-shaped lines (transcript 'Caller: hi') can no longer masquerade as
   metadata.

2. With the vocabulary boundary in place, task-mid bodies become losslessly
   splittable: trusted/lenient body = the task: line's content plus every
   subsequent NON-vocabulary line — health-check bullets and phone
   transcript dialogue stay in body, trailing real headers are excluded.
   Every post-task: line lands in exactly one of headers/body.

Tests: lockstep assertion (guard set == parser vocabulary), forged
non-classic keys (instructions/hint/from/transcript/attempts) defanged,
health bullets + transcript preserved in trusted body, and a corpus-wide
body-fidelity sweep (3,415 archived files: no non-header line lost).
Full suite: injection-guard, dm-fallback-guard, both invariance tests,
interaction-type, gateway, priority — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* test(voice): step-5 behavior anchors — tool-table snapshot + tuned-region tripwires

Pins the voice agent's tuned behavior surface before the LiveAgentRuntime
extraction moves anything: 126 importable tool definitions (name,
description, execution mode, parameter keys + per-key descriptions)
snapshotted to a committed fixture, plus SHA-256 tripwires on the
non-importable tuned regions of voice-agent.ts (instructions factory,
greeting/reconnect factory, tool-table composition — the module runs
main() at load, so tests cannot import it). Every step-5 slice must keep
these green or deliberately regenerate with the diff called out.

Verified environment-independent: identical anchors with an empty
CLAUDE_CONFIG_DIR (manifest tools resolve from the repo tree).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(voice): extract tuned prompt factories into voice-agent-config.ts (step 5a-1)

PURE MOVE of the greeting factory and system-instruction factory out of
voice-agent.ts (which runs main() at module load and is therefore
untestable) into an importable module. voice-agent.ts keeps its state
(mode resolver, meeting flag, session gates) and threads it in through
VoiceConfigContext; env-dependent reads (stand identity, voice context,
repo URL, agent context) run verbatim by default with test-only overrides.

Equivalence evidence:
- all 87 tuned string-array entries identical IN ORDER between the old
  region and the new module (programmatic comparison)
- template-literal set identical except the intentional override branch
- behavior anchors upgraded from source-hash tripwires to REAL factory
  output: fixed-env instruction hash, exact meeting-greeting string,
  conditional-variant assertions (meeting/googleSearch/mode-marker
  placement), reconnect replay-guard shape — 10/10 green across normal,
  repeat, and empty-CLAUDE_CONFIG_DIR runs

tsc clean; task-bridge suites green. Phone (conversation-server) untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(anchors): exclude env-dependent tools from the tool-table fixture

First CI run of the anchors showed the tool table is machine-specific
after all: manifest-loaded skill tools (gws-gmail read_email,
report_feedback, screenshot_explain, …) load on installed hosts but not
on CI — the empty-CLAUDE_CONFIG_DIR simulation missed them because the
manifest scan also covers other paths. inline-tools now exports
envDependentToolNames (manifest tools + presenter-sentinel conditionals)
and the anchor pins the 80-tool STATIC surface, which is identical
everywhere. Excluding per-env extras is sound: a tool absent from an
env's exclusion set is also absent from that env's tool list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(anchors): mask installed-tool lines from the instructions hash

The prompt deliberately embeds the installed tool list, so a raw hash of
buildInstructions output can never be env-stable (CI lacks the manifest
tools). The anchor now hashes the instructions with the two dynamic
segments masked (the joined tool-names line and per-tool '. Instant.'
lines) — pinning all tuned prose exactly — with a separate well-formedness
assertion that the dynamic lines exist. 11/11 in both local and
empty-config runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants