feat(board): governed agent self-tagging via a chat_tag session directive + [BOARD] context line - #7779
Conversation
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of The trust-directory premise checks out ( Design-Verdict: CONCERNS Sound governed design on existing seams, but the two-store commit protocol is hand-inlined three times with its safety ordering living only in comments. Watch
Suggestions
[DESIGN-REVIEWED] 7ed4862 |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed 2 of 2 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands. BLOCKING -- src/kiro_crew/dashboard/chat_tag_grants.py:55 -- Grant policy resides in a sandbox-writable directory
BLOCKING -- src/kiro_crew/dashboard/chat_tags.py:291 -- Tag creation is not crash-atomic across vocabulary and grants
[BLOCK-MERGE] 7ed4862 Adjudication (Opus 4.8) — is blocking on each finding proportionate?I've confirmed the key facts. The adjudicable block is empty (0 findings); two fenced findings remain. F1 — F2 — F1 harm: security-class (forgeable authorization / governance ceiling), condition trivially reachable → UPHOLD-FENCED. 🏷️ Fenced finding(s) machine-flagged as likely edge caseThe security fence keeps these findings blocking regardless of adjudication; the only clearance path is a human override recorded by a repository writer, who must independently verify a rationale before recording it — it is machine-authored, and a wrong override on a security-class finding ships exactly the class the fence exists to stop. (This lane's comment deliberately carries no override command.)
|
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All verification is done. The claims in the change check out against the base tree (the First-Principles-Verdict: CONCERNS The per-tag policy knob ships with no way to set it — zero consumers of the Not justified as shipped
What this change shipsIntent: let an agent move its own session between board workflow states so a human scanning many sessions sees what needs them — ADDITION.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 7ed4862 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
60ab955 to
5a114fe
Compare
Review round 1 — all lanes addressed in
|
5a114fe to
6c7baf8
Compare
Review round 2 — GPT blockings addressed in
|
6c7baf8 to
f8026a0
Compare
f8026a0 to
3a88fc8
Compare
Round 9 disposition — head
|
Round 10 note — head
|
GPT blocking finding taken: the policy source is now protected from agent writesThe finding was correct and is fixed at the root rather than by reverting the mutation path: per-tag agent-write policy no longer lives in agent-writable What changed (
Tests: the policy suite is rewritten against the store, including the finding's exact attack end-to-end (forged The residual trust boundary is stated plainly: the store is enforced against the agent's tool surface by the keystone fence, not cryptographically — a process with unrestricted host access is outside this (and every) data-home control's threat model, same as the sibling keystone leaves. |
|
Re-synced onto current main ( |
All five findings taken (
|
|
GPT round disposition — all four blocking + three findings taken in
Verification: 169 tests across the tag/board suites plus 67 state/body-guard tests pass; black gate (one graduated baseline entry pruned), isort, flake8, mypy, subprocess-encoding and feature-map gates all green. |
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
|
GPT round disposition — all three blocking + the finding taken in
Verification: 133 tag/board tests plus 106 lane/session/state tests pass; black gate, isort, flake8, mypy all green. Note on the prior CI red: |
|
Response to the 2026-09-04 relationship audit — position on each coordination item, from the #7779 side:
The 2026-09-02 audit's four carry-over asks from #3469 (name resolution + vocabulary-enumerating refusal, the v2 governed-target scope note on #7774, the advancement-only record on #3456) were all actioned that same day. |
|
GPT round disposition — all three blocking taken in
Verification: 137 tests across the tag/board suites pass; black gate, isort, flake8, mypy all green. |
|
GPT round disposition — all three blocking taken in
Verification: 157 tests across the tag/board suites plus the context board-line suite pass; black gate, isort, flake8, mypy all green. Trajectory note (pre-commitment): this is the sixth consecutive blocking round on this PR. Each round so far has surfaced genuinely new mechanisms — mostly in compensation code the previous round added — and every finding has been implemented same-round. Finding 2 above is the second refinement of the alias-persistence family (after the earlier live-alias mirror round). If the next round blocks again within the alias-flush/persistence-interleaving family, I will treat it as same-family churn per the repo's deferred-finding discipline and request maintainer arbitration on that axis rather than another unilateral round; genuinely new different-family findings will still be fixed. |
|
GPT round disposition — finding 1 taken in 1. Racing refresh vs authenticated write (blocking, chat_tag_grants.py:157) — TAKEN. Snapshot installs are now serialized behind a module lock, and a refresh re-verifies the on-disk signature at install time: a refresh that read the store before a concurrent write can no longer overwrite the writer's newer snapshot (the writer's install holds the same lock, so every interleaving resolves with the fresh snapshot last). Regression test 2. Durable cross-store recovery under ENOSPC (blocking, chat_tags.py:465) — HELD, requesting maintainer arbitration. This is the fourth consecutive round on the grant/vocabulary compensation axis (r4: restore the prior grant on failed mint; r5: roll back the vocabulary too, and revoke-before-delete with restore; r6: post-mirror re-save; r7: now a durable journal for the restore path itself). Each prior round was implemented; this one asks for a qualitatively different guarantee — recoverability when both stores fail to write, i.e. a write-ahead journal or equivalent transactional machinery spanning Position held for the maintainer to rule on:
Two options for the maintainer to pick between — either will be implemented within one round of the ruling: CI is green on the new head and every other lane is clear; per the repo's deferred-finding discipline this held finding is recorded here rather than silently dropped. |
|
GPT round disposition — the blocking finding taken in Upgrade seeding no longer authorizes deleted default tags (blocking, state.py:6888) — taken, exactly as suggested. The boot seed now mints the code-default workflow-state grants ONLY on the boot that also seeds the default vocabulary (fresh install, no Regression test Verification: 159 tests across the tag/board/state suites pass; black gate, isort, flake8, mypy all green. The r7 held finding (durable cross-store recovery under ENOSPC) remains with the maintainer — the two options from the previous disposition stand. |
|
GPT round disposition — the blocking finding taken in Failed grant creation no longer partially commits the tag (blocking, chat_tags.py:290) — taken, as suggested. A POST creating a status tag whose grant mint fails now rolls the create back in BOTH stores (in-memory vocabulary and the persisted Completeness note for the next round: with this fix, every vocabulary/grant write pair in the module is now transactional in both directions — CREATE (mint fails → vocabulary rolled back), PATCH (persist or mint fails → grant restored + vocabulary rolled back), DELETE (revoke fails → abort; persist fails → grant re-minted). The one residual, by design, is the double-failure case (compensating write ALSO fails, e.g. ENOSPC), which is exactly the r7 finding held for maintainer arbitration — every such interleaving resolves fail-closed. If a further round identifies a pair outside this matrix, it is new; a finding inside the double-failure residual belongs to the held arbitration. Verification: 143 tests across the tag suites pass; black gate, isort, flake8, mypy all green. |
|
GPT round disposition — the blocking finding and both findings taken in
Verification: 145 tests across the tag suites pass; black gate, isort, flake8, mypy all green. |
|
Round disposition — Opus blocking and GPT's non-blocking finding both taken in Windows DACL on the grants store (Opus blocking, chat_tag_grants.py) — taken, exactly as suggested. The store now mirrors Stale comments (GPT non-blocking, chat_tag_grants.py:66) — taken: the row-cap comment now describes reject-whole (never truncation), and the last "stat syscall" phrasing was already corrected in the prior round. Verification: 145 tests across the tag suites pass; black gate, isort, flake8, mypy all green. Note this fix is verified structurally on macOS (the POSIX path is behavior-identical: PR state after this round: GPT cleared on the previous head (ten rounds, all findings implemented or formally held); this push addresses Opus's first blocking round with the repo's own established pattern. The one open maintainer item remains the r7 ENOSPC-durability arbitration. |
|
Round 11 disposition — invoking the round-6 pre-commitment: maintainer arbitration on the alias-flush axis Blocking (session_directive_apply.py:1051 — rebind during the post-mirror re-save can leave stale tags durable): REAL, and the third refinement of one axis — HELD for maintainer arbitration per the pre-commitment. The mechanism as I read it: a dirty alias flush queued BEFORE the mirror can land after our confirmed save; the post-mirror re-save exists to serialize behind it; if the requesting slot rebinds during that re-save's await, the reseal is correctly refused ( This is the same axis three rounds running: live-alias mirror → post-mirror re-save → rebind-during-re-save. In round 6 I pre-committed publicly: another blocking round inside the alias-flush/persistence-interleaving family would go to maintainer arbitration rather than another unilateral compensation layer, because each layer here has spawned the next race. That is exactly what has happened, so I'm honoring it. Two concrete options — I will implement whichever the maintainer picks within one round:
My recommendation is (b): it repairs by convergence through the existing flush machinery instead of adding a fourth synchronous compensation, which is how each of the last three races was created. But I'll take either. Non-blocking (function-local imports at :806): record-keeping. These are the module's documented lazy-import policy (see the module header, added in round 3 when the same convention was raised); the applier's tag-module imports are deferred deliberately. No change. PR state: Opus's Windows-DACL blocking round was fixed and cleared on this head; Design/FP/UX green. Open maintainer items are now two: the round-7 ENOSPC durability option pick, and this round's alias-axis option pick. |
|
Maintainer request: The Fork workflow-change guard (newly enforcing on this PR's re-run; it was skipped on the same head earlier today) flags one file: That deletion is not optional: this PR formats The full -test/test_chat_tags.pyNo workflow files, no CI logic, no other |
|
@bolichen97 Thanks for merging #8726 and #7628 earlier today — much appreciated! When you have a moment, this PR is fully green (all five AI lanes cleared, GPT after 11 rounds) and is waiting only on three maintainer picks to move forward:
Happy to implement whichever way you pick on 1 and 2 within a round. No rush — just flagging it's decision-ready rather than work-in-progress. |
|
Round 12 disposition ( The round-6 pre-commitment sent this axis to arbitration; the arbitration ran as a six-seat cross-model design council (GPT-5.6 ×4 independent samples, Sol, Opus — each given the full round-9→10→11 history, the real code, and a mandate to find the change BOTH review lanes accept and that terminates the axis). The verdict was unanimous: neither option (a) nor keeping the round-10 layer — instead, convergence-by-flusher. Implemented exactly that: The change. (1) The step-2 mirror now marks every mirrored alias Why this terminates the axis. Rounds 9→11 were one family: a synchronous write racing to land last, each new write adding the await the next finding exploited. The pinned step-1 commit already puts the correct state durably on disk; after this change there is NO post-commit await in the applier, so there is no interleaving of that family left to find. Durable reconvergence is now a single invariant — every live slot bound to the transcript holds correct memory and is dirty; the existing periodic flusher reconverges — instead of three compensations. Walk-through of every named race. Queued pre-mirror stale flush lands after the commit → the dirty aliases re-flush correct memory; bounded, self-healing. Requester rebinds mid-await → no post-commit await exists; the pinned commit either confirmed or refused-and-rolled-back. ALL aliases gone → the step-1 commit is already the last write to that transcript (a rebound slot's flush is pinned by its own live history key and cannot touch it) — durable state correct, no action owed; note option (a) FAILS exactly here, having no alias to reseal through. Concurrent second update → same lock, serialized, last committer re-mirrors and re-dirties. Crash at any point → disk holds either the pre-change state (nothing claimed success) or the committed state; the ONLY residual is a stale queued flush landing post-commit and a crash before the next periodic flush tick — bounded by the flush interval, self-healing on restart's first flush, and never a loss of the committed write (it landed once already). That residual is the accepted trade, stated here in advance per the round-6 pre-commitment: no further synchronous compensation will be added on this axis; the levers, if the window is ever deemed too wide, are flush cadence or a shutdown flush hook, neither of which touches this seam. Also taken — a latent bug the council caught on the REFUSAL path ( Mirror-failure logging raised from debug to warning (a persistently failing mirror is now the only path stale memory could survive; it should be visible). Tests: the round-10 re-save test is REWRITTEN to the new contract (exactly ONE save — the pinned commit; aliases and requester dirty); rebound-rollback test extended with the not-dirty assertion; chat_tag suite 53/53, tag_session + board lanes 39/39; black gate, flake8 (CI scope), isort, mypy clean. Open maintainer items are now ONE: the round-7 ENOSPC durability pick. The |
|
Round 13 disposition ( F2 (fenced — agent-controlled IDs enter trusted context, context.py): TAKEN, in the terminal form. The round-12 guard was sanitize-then-screen — a blacklist, so instruction text phrased past the heuristic could still ride the trusted rail, exactly as flagged. F1 (fenced — "authorization store remains agent-writable", chat_tag_grants.py): DISPUTED WITH EVIDENCE, and pinned by a new test. The store lives at Re-sync: rebased onto Suites: chat_tag + directive-invariant 93/93, context 95 passed/2 inherited; black gate, flake8 (CI scope), isort, mypy clean. Open maintainer items unchanged: round-7 ENOSPC pick; |
|
Round 14 disposition ( New blocking (chat_tags.py — upgrading a status tag records it as non-status): TAKEN, exactly as suggested. With no protected record to inherit from, defaulting the mint's status bit either way is wrong (True launders authority from agent-writable tags.json; False strips a status tag's identity and lets F1 (grants store) — position unchanged from round 13: the store is inside the whole-directory governance trust fence (empirically pinned by Also this push: Inclusive Language lane — one added comment reworded (denylist). Comment-history ratchet — my added lines reworded to present-tense invariants, plus the file-total overage in Suites: chat_tags + chat_tag_directive + directive-invariant 185/185 (224 with tag_session/board lanes); black gate, flake8 (CI scope), isort, mypy, comment-history gate all green locally. Open maintainer items: the |
… + [BOARD] context line An agent can now move its OWN session between workflow-state tags (and add/remove ordinary tags) through a stateless chat_tag session directive, under backend-enforced governance (issue kirodotdev#7774 v1): - chat_tag MCP tool (control.py) modeled on set_project; registered in DIRECTIVE_TOOLS; applied via session_directive_apply through the existing tags_write_lock -> validate_folder_tag_ids -> save_slot_off_loop chokepoint, SEL-audited. - Per-tag agent policy (agent: add-remove | add-only | none) resolved in chat_tags.agent_tag_policy; status:True tags default add-remove so the workflow set works with no vocabulary migration; user tags default human-only. - set_state enforces workflow mutual exclusivity across all status:True tags; refuses (never silently strips) a human-only status peer. - Named refusals: tag_policy_denied / unknown_tag / no_op; success result returns the resulting tag list (first agent-readable tag surface). - Cron/subagent turns refused via _USER_SURFACE_DIRECTIVES. - [BOARD] context line injected per turn (tags + agent-writable set), resolved in chat_runner alongside folder_path. Part of kirodotdev#7774 (v1 of 2 — does not close the issue).
|
Round 15 disposition ( F2 (slug grammar admits F3 (rollback erases valid none/false rows): TAKEN, exactly as suggested — and it is the same existence-vs-default seam round 14 fixed at the gate, now fixed at the RESTORE sites. Both compensation paths ( F4 (upgraded sessions retain two workflow states): TAKEN via the suggested F1 (store location): position unchanged — the store is inside the whole-directory governance trust fence, pinned by test since round 13; "sandbox-visible" has not yet come with a concrete write path that the fence's gated surfaces (file tools, shell edit gate, artifacts, dashboard I/O, knowledge indexing) permit. Standing by for that path or maintainer arbitration; relocating an authorization store out of the audited fence on assertion alone would weaken it. Non-blocking (function-local imports): the module's documented lazy-import policy; no change. Suites: 211/211 across chat_tags/chat_tag_directive/directive-invariant/tag_session; comment-history, black gate, flake8, isort, mypy clean. |
|
@jeeshofone Thanks for this, and sorry for the wait. Reviewing it as part of a repo-wide open-PR relationship audit (audited at 55a9e96; your head has since moved to 7ed4862 and the file set is unchanged). Three open PRs touch it. #7877 (@billygerhard) delivers the same end-user capability, an agent moving its own dashboard session between mutually exclusive workflow tags, through a different mechanism: a credentialed #8185 (@Pearcekieser) makes #8696 (@chenmingwei23) adds an exhaustive hostile-call table over Separately, this branch is well behind main and currently conflicting, so please rebase before we judge merge state. Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
Problem / Motivation
A user managing many concurrent sessions has no way to see at a glance what is pending on them versus what agents are still working. Agents cannot participate in board organization at all: no MCP tool can set a workflow-state tag, and an agent cannot even read its own session's tags. The board only stays truthful through manual grooming.
Why it matters
Handoff visibility is the core value of a session board: an agent that finishes its work should be able to tag its own session
Reviewon its last action, so filtering byReviewanswers "what needs me" across dozens of sessions. Without an agent write path, workflow tags rot the moment the user stops hand-maintaining them.What changed (motivation → approach → change)
Goal: let agents move their own session between workflow states — under governance, because the board is also the human's tool. Approach chosen (over a backend rules engine and over unrestricted tooling): agents own the judgment of when to move; a fixed backend authorization layer owns who may move what. "Turn ended with nothing actionable ⇒ Review" style inference was deliberately rejected — the agent must explicitly declare the transition. Design discussed in #7774.
Built (v1 of the issue's two-stage plan — self-only, tags-only):
chat_tagMCP session directive (mcp_tools/control.py, modeled onset_project; registered inDIRECTIVE_TOOLS; two-layer schema with acustom_validatorenforcing at-least-one ofset_state/add/remove). Applied insession_directive_apply.pythrough the existingtags_write_lock → validate_folder_tag_ids → save_slot_off_loopchokepoint, with freshslot.tagsreads inside the lock and SEL audit (chat.self_tag, allowed/denied).chat_tags.agent_tag_policy/agent_tag_grant, backed by the newdashboard/chat_tag_grants.py):add-remove|add-only|none, resolved from<data home>/trust/agent-tag-policy.json— thetrust/directory is an existing whole-directory keystone entry, so the agent's own file tools cannot read or write the policy source (GPT review finding: fields on agent-writabletags.jsonrows could be forged and survive restart). Rows are minted only by the authenticated dashboard tag CRUD (create mints the out-of-the-boxadd-removefor workflow-state tags; PATCH accepts anagentpolicy value; delete revokes), with a one-time trust-on-first-use seed at boot deriving rows from the pre-store vocabulary fields. The applier's status semantics (set_state eligibility, peer strip, no-status-through-add) also key on the store's recorded status bit. Everything fails closed tonone/non-status.remove— including the implicit removal inset_state— requiresadd-remove.set_statestrips every otherstatus: Truetag, keyed on the flag rather than a hardcoded id list so custom status tags participate; a human-only status peer is refused rather than silently stripped.tag_policy_denied:<tag>,unknown_tag:<tag>,no_op) and a success result carrying the resulting tag list — the first agent-readable tag surface.chat_tagadded to_USER_SURFACE_DIRECTIVES, so cron/subagent turns are refused.[BOARD]context line (context.py+chat_runner.py): one per-turn line showing the session's tags and which are agent-writable, resolved inchat_runneralongsidefolder_pathsocontext.pystays free of dashboard/state imports.Out of scope here (v2, tracked in #7774): folder moves, conductor-over-children, propose-confirm for human-placed state, move budgets/cooldowns.
Tests
New
test/test_chat_tag_directive.py(plustest_session_directive.pyadditions):set_state reviewreplaces an existing workflow tag and returns resulting tags (mutual exclusivity).none-policy tag is refusedtag_policy_denied; anadd-onlytag adds but refuses removal (asymmetric add/drop).unknown_tag; repeated state isno_op._USER_SURFACE_DIRECTIVES.DIRECTIVE_TOOLS).state._tags_authoritative=Truemirroring real boot.Suites run locally:
test_chat_tag_directive.py,test_session_directive.py,test_chat_slot_project.py,test_mcp_tool_registry.py,test_context_management.py— 179 passed; black gate clean.Manual verification
N/A — unit coverage sufficient: the directive path, policy matrix, exclusivity, and surface gating are all exercised end-to-end at the applier; the
[BOARD]line is a plain text injection covered by context tests.Related Issues
Part of #7774 (v1; the issue stays open for v2 — folder moves and conductor scope).
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)