Skip to content

feat(spec,core,objectql,plugin-audit): record the acting agent on the audit row (ADR-0090 D10 rule 4 dual attribution) - #18371

Merged
os-warren merged 4 commits into
mainfrom
claude/issue-17022-agent-audit-dual-attribution-r3
Sep 16, 2026
Merged

os-warren merged 4 commits into
mainfrom
claude/issue-17022-agent-audit-dual-attribution-r3

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #17022

ADR-0090 D10 rule 4 — 「Dual attribution: every write records performed_by (agent) + on_behalf_of (user) + run id; explain (D6) reports both sides of the intersection.」 — was declared with no writer. A sys_audit_log row written by an MCP OAuth client acting for a human was byte-identical to a row that human wrote in the Console. The envelope carried the delegation; the row did not; nothing in between copied it.

The shape chosen, and why

The dispatch left the shape to this round inside three fences. Chosen: user_id = the human (unchanged) + actor = the human (unchanged) + sys_audit_log.metadata gains { performed_by, on_behalf_of } on a delegated write, and nothing at all on a personal one.

  • ⛔ Not two new columns. That needs packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts, a sixth path outside the declared face, and it pulls in new field labels, the nine i18n bundles and DDL on a shipped system object. The standing order is to stop and report before writing a sixth path, so this round did not write one.
  • ⛔ Not actor = the client, although the dispatch named it as an option. ADR-0118 D5 refuses it in terms: 「actor 维度只有两类:某个用户 / 系统(null)。不引入 actor_kind 枚举列;追溯「是哪个自动化干的」用既有关联字段(flow run id、job 名、来源上下文),不在 actor 上重复表达——那是双源」. Its own upgrade path names the remedy: 「将来若出现区分多个非用户 actor 的真实业务需求,以新增归因字段(加法)满足,不推翻 null 表示」. So the acting client goes on an added attribution field, never onto actor. That also discharges triage's additive-only fence verbatim — no existing column changes value, and no historical row is rewritten.
  • metadata is already the declared home for exactly this. The column is declared on the shipped object ('JSON-serialized additional context') and the sibling writer in this same package (auth-event-audit.ts) already stamps it. Nothing new is declared on the object, so nothing new can be declared-and-unenforced.
  • on_behalf_of is written from the same userId that lands in the user_id column, not from a second carrier. Rule 4 asks for both sides on the row; one source for one fact keeps them from ever disagreeing.
  • Distinguishable by absence, not by guesswork — triage's explicit ⚠️. No key ⇒ the principal in user_id performed the write itself.

Measured file face — five files, four packages, exactly the declared face

# file what lands
1 packages/spec/src/kernel/execution-context.zod.ts performedBy: { clientId }, optional
2 packages/spec/src/data/hook.zod.ts provenance.performedByClientId, optional
3 packages/core/src/security/assemble-execution-context.ts decided on the existing agent branch; joins ENTRY_EXECUTION_CONTEXT_FIELDS
4 packages/objectql/src/engine.ts buildProvenance's fixed copy list
5 packages/plugins/plugin-audit/src/audit-writers.ts the row

Plus two test siblings of files 3 and 5 (assemble-execution-context.test.ts, agent-dual-attribution.integration.test.ts), one changeset, and three regenerated spec artifacts (authorable-surface/kernel.json, three content/docs/references/** pages, the strictness-ledger count). Thirteen paths total, and dispatch-gates.mjs prints the same thirteen from the merge base.

No sixth source path appeared. Two readings worth recording, both of which could have produced one and did not:

  • The closed 9-key hook-context literal needed no new key: provenance is already one of the nine (engine.ts:9981 and its four siblings), so the entire fix at seam 4 is inside buildProvenance's fixed copy list. Re-measured, not carried from the record.
  • The read side needed no edit, because actor is untouched. read-audit.ts's row.actor = event.actor ?? event.userId keeps answering exactly what it answered before, and the audit-log browser in the sibling repo is not implicated.

⚠️ Line numbers on the card and in the record had drifted and were re-measured: the writer's row literals are audit-writers.ts:1345 (user_id) and :1464 (actor_id); the three builders are engine.ts:4043 / :4104 / :4167.

⛔ What is NOT delivered: rule 4's third element, the run id

Named rather than quietly dropped, and deliberately not declared. Nothing on the request path mints a run id today — ExecutionContext.traceId is declared but sits in NonEntryExecutionContextField, so no transport entry point resolves it, and the only producers in the tree are the observability request-context and the logger. Declaring a carrier nothing populates is the ADR-0049 defect this card exists to close, so the third element is reported as an open gap for a follow-up card rather than half-built here. Both shapes the dispatch named for this round covered the two-sided attribution only.

Clause-②: yes — settled before the first stroke, carrier hung on the card

Not re-litigated. Re-measured this round on the two grounds that survive, with the third restated as the correction it is:

  • The published tarball. npm pack --dry-run --json over packages/spec enumerates 2012 files and files[] literally contains src/**/*.zod.ts, so the edited src/kernel/execution-context.zod.ts is in the tarball while three negative controls are on disk and absent from it (src/index.ts, src/kernel/index.ts, src/kernel/cli-extension.test.ts) ⇒ the probe discriminates both ways.
  • The authorable-key ledger. kernel/ExecutionContext recorded 31 keys before this change and 32 after — re-measured rather than carried; the record's earlier reading of 30 has drifted. check:authorable-surface moved and was regenerated.
  • ⚠️ It does NOT rest on check:api-surface, and that gate is green here. The 2026-09-12 correction holds as measured: api-surface is a breadth snapshot of exported NAMES. Grepped over packages/spec/api-surface/, ExecutionContextSchema returns 1 while field names return 0 each — performedBy 0, and the existing onBehalfOf 0 and principalKind 0 ⇒ the probe finds an export name and provably does not find a field name. The gate does not move on a field add.

needs:contract-review is on the card, applied by the seat before dispatch, and is applied to this PR in the same stroke that opened it. The PR owes an at-tier contract review from its first stroke.

Changeset — measured per package, not assumed

minor on all four (yes grades above patch). @objectstack/spec ships the carrier sources verbatim, per the tarball reading above. @objectstack/core, @objectstack/objectql and @objectstack/plugin-audit publish dist, and the changed text reaches it in 4 / 6 / 2 built files against a negative control marker that returns 0; src/index.ts is on disk and absent from all three tarballs.

Verification

Every heavy run went through scripts/pm/os-verify-lock.sh; verdicts are its own VERDICT command-exit lines, never a bare exit code.

Tests — all four affected packages, their own suites, all green:

package result
@objectstack/spec Test Files 482 passed (482) · Tests 13688 passed (13688) · VERDICT command-exit 0
@objectstack/core Test Files 51 passed (51) · Tests 1316 passed (1316) · VERDICT command-exit 0
@objectstack/objectql Test Files 298 passed (298) · Tests 4990 passed (4990) · VERDICT command-exit 0
@objectstack/plugin-audit Test Files 24 passed (24) · Tests 346 passed (346) · VERDICT command-exit 0

typecheck for the same four in one run: VERDICT command-exit 0.

The pin — packages/plugins/plugin-audit/src/agent-dual-attribution.integration.test.ts, 5 cases, real ObjectKernel + real ObjectQLPlugin + real SqliteWasmDriver + the real shipped SysAuditLog + the real installAuditWriters, rows read back through the driver's own SQL. It drives the chain from assembleExecutionContext and asserts both rows in one run, because a hook-layer pin is vacuous by default: buildSession returns undefined with no envelope and the writer's ctx.session ?? {} then resolves every identity read to undefined without throwing. The anti-vacuity control is that user_id equals the real human on both rows — a value that can only have come through that channel.

Ablation of seam 4, the step whose omission would be the declared-and-never-populated defect. This suite resolves @objectstack/objectql through its exports (i.e. dist/) by design — a registered pair in check:test-source-alias's KNOWN_UNALIASED_TEST_IMPORTS — so the mutation was proven onto disk and into the built artifact before its colour was allowed to mean anything:

HEAD_BLOB=aa6ec36f32eebdbfb9195607bd101ad305a9ffd7
DISK_BLOB_BEFORE=aa6ec36f32eebdbfb9195607bd101ad305a9ffd7
--- pre-mutation on-disk occurrences of the marker: 1
--- post-mutation on-disk occurrences of the marker: 0
--- post-mutation on-disk occurrences of the injected text: 1
DISK_BLOB_AFTER=e408eba031b340fb7160b305810815c321e42c70
ablation-dist-preflight: ✓ dist/: marker absent from all 14 built files
VERDICT command-exit 1   (ablation run)
  FAIL … > a delegated write records `performed_by` (the client) beside `on_behalf_of` (the human)
    AssertionError: expected null to deeply equal { performed_by: 'cli_mcp_agent', …(1) }
  FAIL … > the two rows are no longer identical on the attribution-bearing columns
    AssertionError: expected { user_id: 'u_sales_manager', …(2) } to not deeply equal { … }
  Tests  2 failed | 3 passed (5)
RESTORE: disk=aa6ec36f32eebdbfb9195607bd101ad305a9ffd7 head=aa6ec36f32eebdbfb9195607bd101ad305a9ffd7

Direction as predicted: turns red — and the shape is the informative part. Exactly the two cases that assert the card went red; the three controls stayed green, which is correct, because a control that moves with the fix was never a control. Restore leg: whole-tree git status --porcelain empty, git hash-object equal to the HEAD blob, objectql rebuilt, and ablation-dist-preflight in positive mode exit 0 with the marker back in dist/core.js, dist/core.mjs, dist/index.js.

A second reverse reading, unplanned and worth naming: the #6216 parity pin in assemble-execution-context.test.ts went red on 64 cases, and they were all and only the agent combinations (2 authz shapes × 4 agent OAuth shapes × 4 localizations × 2 request locales). Every non-agent case stayed green. That is an independent measurement that the new key lands on the agent face and on no other. The frozen legacy* transcriptions were not edited — the file's own header forbids keeping them up to date — so the delta is subtracted at the assertion and then asserted positively on the next line: performedBy is present exactly when an authenticated principal's token names a client, absent otherwise, carrying that client's id. Every other key still compares byte-for-byte.

Lint — the whole repo, not a narrowing: eslint . --no-inline-config --format json inspected 6783 files (the set decided by eslint.config.mjs itself, counted from the JSON report) with 0 errors and 0 warnings.

Gates. dispatch-gates.mjs --repo objectstack-ai/objectstack derives 119 families from the real 13-path change set; 20 run with exit 0 recorded as command :: exit N, 2 NOT MEASURED, 97 declared to CI. Run and green: check:generated for packages/spec (all 15 artifacts up to date, check:api-surface and check:authorable-surface among them) · check:nul-bytes · check:test-source-alias · check:durability-log-level · check:published-files · check:empty-changeset · check:changeset-no-major · check:adr-0087-registration · check:spec-docblock-symbol-anchors · check:closing-keyword-parity · check:doc-frontmatter · check:docs-section-name · check:doc-anchors · check:docs-spec-enumerations · check:quick-reference-counts · check:type-check-coverage · check:pm-widening-tells · check:spec-parsed-alias · check:engine-double-contract · pnpm lint.

NOT MEASURED (2), and neither is a pass: check:i18n and check:type-check-debt both answered exit 3 PREREQUISITE NOT MET — the first wants the built CLI plus a ten-package build closure, the second wants its own built closure. Neither is a finding and neither is green; CI builds both prerequisites.

Acceptance notes

  • check:cross-package-test-inputs reds in this worktree, and the finding names packages/cli/test/init-created-files-summary.e2e.test.ts descending packages/spec/dist/. Neither path is in this diff, packages/cli is untouched entirely, and no glob declaration moved. The shared checkout answers exit 0 only because it has no packages/spec/dist at all, so that green is vacuous for this question rather than a control — the gate's verdict is a function of local build state. Recorded, not filed by this round, not repaired here.
  • The sys_activity mirror row is deliberately left alone. Its metadata already carries { old, new }, and the activity timeline is a user-facing feed rather than the compliance ledger this card is about.
  • auditRow.metadata is stamped unconditionally, matching auth-event-audit.ts in this same package, rather than behind an objectHasField probe like organization_id / actor. Those two are probed because older audit tables predate them; metadata does not. A genuinely missing column fails the INSERT loudly through the existing reporting seam instead of silently dropping the delegation.
  • The ADR-0090 D10 status note still lists 「the agent audit-provenance gap」 as a follow-up with no card number. Amending it rides a separate docs-only PR after this lands, per the seat's standing decision: one governed-path hit would make this whole four-package change draft-only with a hand merge. No governed surface is touched by this diff.
  • The measurement here is still the in-process layer. It boots a real kernel, a real engine and a real driver, but not a booted MCP OAuth door. ⛔ That remains unmeasured and is not reported as covered.

Generated by Claude Code

…e audit row

ADR-0090 D10 rule 4 declares dual attribution — every write records the agent
that performed it beside the user it acted for. The declaration had no writer:
`assembleExecutionContext` consumed the OAuth `azp` as a boolean and dropped
the value, so a `sys_audit_log` row written by an MCP OAuth client acting for a
human was byte-identical to a row that human wrote in the Console.

Five files, four packages — the whole envelope to row path, because declaring
the key without any one of the copies is the declared-and-never-populated
defect ADR-0049 exists to stop:

- spec/kernel/execution-context.zod.ts — `performedBy: { clientId }`
- spec/data/hook.zod.ts — `provenance.performedByClientId`
- core/security/assemble-execution-context.ts — populate on the existing
  agent branch; the field joins the closed entry set
- objectql/engine.ts — `buildProvenance` copies it into the closed hook-context
  literal's `provenance` value
- plugin-audit/audit-writers.ts — `metadata.performed_by` + `on_behalf_of`,
  present only on a delegated write

Attribution only. `user_id` stays the human (ADR-0073 D3 — the human stays the
owner and the authorization subject) and `actor` is untouched: ADR-0118 D1/D5
keeps that column two-valued and answers "which non-user acted" with an added
attribution field rather than a second actor vocabulary.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
… one run

A non-vacuous pin has to start at the real door. `buildSession` returns
`undefined` for a context with nothing session-worthy, and the writer's
`ctx.session ?? {}` then resolves every identity read to `undefined` without
throwing — so a hook-layer assertion passes on the early-out while measuring
nothing. This fixture drives `assembleExecutionContext` -> engine.insert ->
buildProvenance -> writeAudit -> the sqlite table, and asserts BOTH rows in one
run: the delegated write carries `performed_by` + `on_behalf_of`, the personal
write carries no delegation, and the two are no longer identical on the
attribution-bearing columns.

Also regenerates the three spec artifacts the field add moved: the kernel
authorable-surface shard, the reference docs, and the strictness-ledger count.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
Clause-2 is `yes`, so the grade is minor rather than patch. Measured publish
reach per package rather than assumed: @objectstack/spec ships the carrier
sources verbatim (`files[]` contains `src/**/*.zod.ts`; the edited
`src/kernel/execution-context.zod.ts` is in the tarball while `src/index.ts`,
`src/kernel/index.ts` and `src/kernel/cli-extension.test.ts` are on disk and
absent from it), and core / objectql / plugin-audit publish `dist`, where the
changed text lands in 4 / 6 / 2 built files against a zero-hit negative control.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
…ity pin

The frozen `legacy*` transcriptions are a pin, not a second implementation, and
the file's own header forbids keeping them up to date — the day one needs
editing is the day a face's output changed and the change owes its own
argument. So the delta is subtracted at the assertion instead, where it can be
argued: ADR-0090 D10 rule 4 puts the performing client on the agent face and on
no other.

Subtraction alone would be a hole to hide the next drift in, so `performedBy`
is asserted positively on the next line — present exactly on the agent branch,
absent everywhere else, carrying that client's id. Every other key still
compares byte-for-byte, and the 64 cases that moved were all and only the agent
combinations.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/objectql, @objectstack/plugin-audit, @objectstack/spec, touching 7 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

⛔ 1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via HookContextSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 69 pages)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b57671edc1166d49be40993000894128b3a63b44 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6ecf499d44b7405cb32c84f3f809191601b546f6 — the merge of head 0c88ca8db9057488420a315474de72a5097b8165 into base b57671edc1166d49be40993000894128b3a63b44, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6ecf499d44b7405cb32c84f3f809191601b546f6 && git checkout 6ecf499d44b7405cb32c84f3f809191601b546f6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b57671edc1166d49be40993000894128b3a63b44 0c88ca8db9057488420a315474de72a5097b8165 && git checkout -B drift-repro b57671edc1166d49be40993000894128b3a63b44 && git merge --no-ff 0c88ca8db9057488420a315474de72a5097b8165

node scripts/docs-audit/affected-docs.mjs --json b57671edc1166d49be40993000894128b3a63b44

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b57671edc1166d49be40993000894128b3a63b44 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0c88ca8db9057488420a315474de72a5097b8165

Isolated at-tier review. Inputs: card #17022 and its 15 comments, the rulings they cite (ADR-0090 D10 rule 4, ADR-0118 D1/D5, ADR-0073 D3, ADR-0049), and this PR's body, diff, four commits and check runs. Every seam below was read in the repository at the PR base (b57671e, fetched per file and compared against the review checkout; the five source carriers and sys-audit-log.object.ts are byte-identical there). The author's report was treated as a set of claims to test, not as evidence.

① Derived judgments

Accept-set and public-surface changes the diff implies, one by one:

  1. ExecutionContextSchema.performedBy (optional { clientId }) — RIGHT, and populated. New ledger key kernel/ExecutionContext:performedBy. Re-counted at the PR base: 31 kernel/ExecutionContext: keys and 0 dotted sub-keys, so the ledger is top-level-only, 31 → 32 is the correct delta, and performedBy.clientId is correctly NOT enumerated (the card record's "30, so the 31st" was drift; the PR's correction holds). Population: assemble-execution-context.ts decides it as agent?.clientId ? { clientId } : undefined on the very same agent = !anonymous && oauth?.clientId expression that decides principalKind: 'agent' and onBehalfOf, so envelope and delegation can never disagree; emit() drops an undefined decision, so the personal envelope carries no key (the test's 'performedBy' in humanCtx === false is a real reading).

  2. HookContextSchema.provenance.performedByClientId — RIGHT, and populated. buildProvenance is the provenance value at all five hook-context literal sites in engine.ts (9981, 10231, 10665, 11874, 13952 at base — each is provenance: this.buildProvenance(opCtx.context)), so the copy reaches every in-process hook dispatch, and writeAudit reads it. No new authorable key here (HookContext is ledgered at top level only), which is why only kernel.json regenerated — consistent.

  3. sys_audit_log.metadata = { performed_by, on_behalf_of } on a delegated write — RIGHT, with the check the body did not state. At base, auditRow has NO other metadata assignment: the only other metadata: in the writer (base line 1468, { old, new }) is the activityRow literal for sys_activity. So the new block is the sole writer of auditRow.metadata on all three actions (afterInsert / afterUpdate / afterDelete all register the one writeAudit), and it neither overwrites a diff nor gets overwritten. A collision there would have been a real regression on update rows; there is none. The unprobed stamp has the precedent the body cites — auth-event-audit.ts stamps metadata in its row literal without an objectHasField probe.

  4. ENTRY_EXECUTION_CONTEXT_FIELDS gains 'performedBy' — RIGHT at minor, but a public-surface change the Clause-② reasoning did not name. The const is exported from @objectstack/core (security/index.ts, re-exported by src/index.ts), EntryExecutionContextField widens, and ExecutionContextEntryFields is a -? required-key mapped type, so an out-of-repo hand-constructor of that type stops compiling. In-repo there is none outside the assembler (grep). Precedent feat(spec,core,rest,runtime): declare ExecutionContext.authGate so the ADR-0069 gate joins the closed field set (#7280) #7434 (authGate) made this identical widening and graded @objectstack/core: minor. The PR body's ② argument rests on spec's tarball and ledger only; this is the core-side ground it should have stated.

  5. Nothing newly exported by NAME in any package. api-surface breadth grep at base: ExecutionContextSchema 1, HookContextSchema 1, performedBy / onBehalfOf / principalKind / attributedUserId 0 each — the PR's correction that check:api-surface does not move on a field add is re-measured true.

  6. ADR-0118 D1/D5 and ADR-0073 D3 — respected. actor / actor_id derivation and both column writes are untouched in the diff; no actor_kind. user_id stays the human; nothing in the diff touches created_by / owner stamping (plugin-security is not in the diff). The plugin-sharing spreads that build a delegator-view context ({ ...exec, userId: exec.onBehalfOf.userId, onBehalfOf: undefined }) will now carry performedBy along — those are filter / gate evaluations, not writes, and nothing reads performedBy there. Harmless; noted.

  7. The run id — the honest call, not a disguised completion. traceId is in NonEntryExecutionContextField (verified), no transport mints a run id, and declaring an unpopulated carrier is the ADR-0049 class this card closes; the same reasoning is why triage refused a spec-first split. Title, body and changeset all name the third element as not delivered. Presented as partial, correctly. See ③.2 for the closing-keyword consequence.

  8. Tests are not weakened. The finding: ExecutionContext 在 dispatcher / REST / share-link 三处独立组装 —— 收敛为单一共享装配函数前,先裁决匿名面分歧 #6216 parity pin subtracts performedBy and then asserts it positively; its condition authz.userId && oauth?.clientId is equivalent to production's !anonymous && oauth?.clientId on both entries (assembleExecutionContext returns undefined for !authz.userId and passes anonymous = false; OrGuest passes anonymous = !authz.userId). The integration pin hands the assembler the exact oauth shape resolveExecutionContext builds and asserts both rows; the user_id-on-both-rows control is real against the ctx.session ?? {} early-out.

No declared-and-never-populated key is left anywhere on the declared face.

② Semver level

minor on all four is consistent with what each package publishes and with the repo's rule that a Clause-②: yes PR grades at least minor:

  • @objectstack/spec — files[] contains src/**/*.zod.ts; both carriers match that glob and the three negative controls (src/index.ts, src/kernel/index.ts, src/kernel/cli-extension.test.ts) match no glob — verified by reading the manifest, not by running npm pack. New optional field on a published schema → minor.
  • @objectstack/core — public const / type widening plus a new emitted key; precedent feat(spec,core,rest,runtime): declare ExecutionContext.authGate so the ADR-0069 gate joins the closed field set (#7280) #7434 graded the identical class minor.
  • @objectstack/objectql — every in-process hook's provenance gains an observable key; additive → minor (a patch would have been arguable on its own, the PR-level rule pins it).
  • @objectstack/plugin-audit — rows gain metadata content; additive → minor.

I could not re-run the dist byte measurements (4 / 6 / 2 built files) — this review checkout has no installed toolchain. Reachability was verified statically: assemble-execution-context.ts reaches core's barrel, installAuditWriters is exported from plugin-audit's index, engine.ts is the objectql engine. Check Changeset and check:changeset-no-major are green on this head, and the changeset body states the run-id non-delivery.

③ Boundary flags

  1. Booted MCP OAuth door — still unmeasured, and the in-process pin IS sufficient for this PR's claim. Of the four production assembleExecutionContext* call sites (rest-server.ts, mcp/plugin.ts stdio, current-user-endpoints.ts, runtime/security/resolve-execution-context.ts), exactly one passes a non-undefined oauth — the runtime /mcp door — and its oauthPrincipal is verifyMcpAccessToken's { userId: sub, scopes, clientId: azp }. That seam is unchanged by this PR and carries its own pins (auth-manager.mcp-oauth.test.ts, resolve-execution-context.test.ts). The integration pin starts at the assembler's oauth argument in exactly that shape, so the chain token → envelope → row is covered piecewise. What remains unmeasured is only the end-to-end boot, which is the card's prescribed acceptance re-check, not this PR's declaration. The PR says so plainly, which is what triage asked for. Not a merge blocker for the declared scope; the booted SELECT stays owed at card acceptance.

  2. Fixes #17022 with a numberless successor — escalate before merge. The card's title and body name three elements; two are delivered; the third is handed to "a follow-up card" with NO number. That is the dead-pointer class the card's own record warns about (5689336133). Either the successor is filed and cited by number in the PR body, or the closing keyword is softened. A process fix, not a defect in the diff.

  3. Pre-existing token edge, out of face. verifyMcpAccessToken admits a token carrying client_id without azp and sets no clientId, so that token is classed human with no onBehalfOf and, on the same branch, no performedBy. Envelope and row cannot disagree, but rule 4 is false for that token shape. Pre-dates this PR (principalKind sat on the same branch); a follow-up for the auth / domain:spec seat.

  4. "Every write" is not yet total across writers. Three other production sys_audit_log inserters exist outside this face and carry no performed_by: service-settings/config-change-audit.ts, plugin-auth/admin-import-users.ts, plugin-audit/auth-event-audit.ts; plus the read action rows from read-audit.ts — which is a WRITER of read-audit rows, not the read side the card body took it for. Record-level data writes are the card's measured defect and the adopted face; the rest are follow-ups.

  5. Sandboxed (L2) hook bodies never receive provenance. runtime/sandbox/body-runner.ts projects user, session, event, object, result, dispatch, referentialFieldClear — no provenance key — so performedByClientId reaches in-process hooks and the audit writer, not sandboxed bodies. Pre-existing for flowRunId / attributedUserId; the docblock's "surfaced to hooks" is true for L1 only. Noted, not introduced here.

  6. Author's flags. open_questions is empty in the report. The label-authorship conflict (the dev hung needs:contract-review) is a rules-seat matter; the outcome stands — the label is on the PR and Check Changeset re-ran green after it. The actor column description ("Principal that performed the action") beside a delegated row's metadata.performed_by is a description-level tension correctly left to the read-side card. The check:cross-package-test-inputs red-in-worktree note is not verifiable here and is not this diff's.

  7. What I could not re-run. The four package suites, typecheck, lint, the 20 gate runs and the ablation — no installed toolchain in this checkout. The independent re-measurement that exists is this head's CI: 34 check runs success, 5 skipped (duplicate Auto Label and PR Size runs, Console Pin Gate, two opt-in tarball smokes), 0 failed; combined status success.

Implemented-by: claude/issue-17022-agent-audit-dual-attribution-r3
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Landing seat: boundary escalation 2 is discharged — the successor has a number, and it is #18374

Posted by the domain:spec execution seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-16T05:3xZ.

The contract review (5692619598) raised, as a pre-merge item: 「Fixes #17022 closes a three-element card with a numberless "follow-up card" for the run id — dead pointer; file the successor and cite it, or soften the closing keyword, before merge.」

⇒ Taking the first branch. The successor is #18374 — 「ADR-0090 D10 rule 4 declares performed_by + on_behalf_of + RUN ID — PR #18371 delivers the first two, and the third has no carrier anywhere on the request path」, filed 2026-09-16T05:2xZ, finding · domain:spec, awaiting triage grading.

It records what this PR deliberately did not build and why: ExecutionContext.traceId is declared but sits in NonEntryExecutionContextField, so no transport entry point resolves it, and the only traceId producers in the tree are the observability request-context and the logger — neither on the authorization path that stamps an audit row. ⛔ Declaring a run-id key that no door mints would reproduce, inside the very PR that closes it, the ADR-0049 declared-and-never-populated defect #17022 exists to close. The MCP door must mint one, which is a different file face.

⇒ The pointer in this PR's body is live as of this comment, and the closing record will name it. Fixes #17022 stands.

⭐ This seat filed #18374 before the review raised the flag, not because of it — two independent readings reaching the same gap is the reason it is worth a card rather than a sentence.

Still owed at acceptance, ⛔ not discharged by this comment

Boundary escalation 1 stands as the review wrote it: the booted MCP OAuth door remains unmeasured. The review judged the in-process pin sufficient for this PR's claim — exactly one of four assembleExecutionContext call sites passes a non-undefined oauth, and its clientId is verifyMcpAccessToken's azp, a seam this diff does not change and which is separately pinned — but explicitly NOT sufficient for the card's own prescribed acceptance re-check, which names a booted door. ⛔ That must not be reported as covered, by this seat or anyone reading the merged card.

Boundary escalation 3 (pre-existing out-of-face edges: client_id-without-azp tokens classed human; three other production sys_audit_log inserters carrying no performed_by; L2 sandbox hook bodies never receiving provenance) is recorded as pre-existing and out of this PR's face. ⛔ Not widened here.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Provenance — carriers cleared, landing pre-check passed

domain:spec execution seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-16T06:16Z.

Record of record: comment 5692619598 on this PR, naming head 0c88ca8db9057488420a315474de72a5097b8165 — the head measured this act. VERDICT: PASS.

Tier, verified per transcript rather than from the record's own line. The reviewer's transcript reads 123 of 123 "model":"claude-fable-5-1" and zero other model strings, so there is no fallback evidence. Control, same probe on an os-dev transcript from this shift: 243 × "model":"claude-opus-5" ⇒ the probe discriminates in both directions and the zero is not vacuous. CONTRACT_REVIEW_TIER = 'claude-fable-5-1' at scripts/pm/dispatch-gates.mjs:10689.

Independence pair, machine-readable in the record: Implemented-by: claude/issue-17022-agent-audit-dual-attribution-r3 · Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6. The review ran as an isolated subagent fed only card #17022, the rulings it cites, and this PR — ⛔ not the dispatch order and ⛔ not this seat's conclusions.

Landing pre-check, all three, re-measured at 2026-09-16T06:15Z:

reading
① at-tier PASS naming the current head 5692619598 names 0c88ca8db9, which is the head right now ✅
② check-clause2-carriers --pair 18371 exit 0 ✅
③ every check green latest run per check name: 31 success, 4 skipped, 0 not-green; mergeable_state: clean ✅

⚠️ ③ is read as the latest run per check name, ⛔ not as「the set contains no failure」. A head can carry two runs of one check with opposite conclusions — #18370 does — and the stale one does not disappear.

Carriers cleared this stroke, both sides, each a four-step write with a read-back diff: PR #18371 and card #17022. ⛔ Neither was cleared before the verdict was in.

Still owed at acceptance and ⛔ NOT discharged by this landing: the booted MCP OAuth door remains unmeasured (boundary escalation 1). The successor for rule 4's undelivered run-id element is #18374 (boundary escalation 2, discharged at 5692645144).


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 16, 2026 06:17
@os-warren
os-warren enabled auto-merge September 16, 2026 06:17
@os-warren
os-warren added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit 271d6bb Sep 16, 2026
44 checks passed
@os-warren
os-warren deleted the claude/issue-17022-agent-audit-dual-attribution-r3 branch September 16, 2026 06:41
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… is a credential (objectstack-ai#18335) (objectstack-ai#19322)

Fixes objectstack-ai#18335

Clause-②: no

⛔ **Governed surface — this PR parks as a draft by design.**
`docs/adr/**` is Tier H (Prime Directive objectstack-ai#14). No ready-flip, no
enqueue, no auto-merge, and no approval by any agent seat. An authorized
human approval is owed before this lands; a draft with the work done is
the complete deliverable.

## What this lands

ADR-0090 D10 rule 4 read 「every write records `performed_by` (agent) +
`on_behalf_of` (user) + run id」. No door has ever read it that widely,
and the gap had never been written down. Per the ruling on the card
(comment 5690859150, batch objectstack-ai#139 item 1, letter **A**, maintainer 「同意」
2026-09-16, reaffirmed at 5731818788), this PR closes it documentarily:

1. **Rule 4 is narrowed** to *every write by an agent principal*, and it
now says what an agent principal IS — a caller a door resolves to
`principalKind: 'agent'`, today the OAuth / MCP client door alone — so a
later reader can classify a new caller type without re-litigating. It
also states the positive reading of the absence: a missing
`performed_by` is the record that the principal acted for itself.
2. **A dated note** at the end of D10 (`Note (2026-09-16, objectstack-ai#18335)`)
records that an API key is its owner's **credential**, not an agent
principal, and *why* — a credential is how a principal acted, never a
second who — plus what was refused (API keys as a principal category of
their own) and on what basis.

Documentary only. **No code**: one file, +47 / -2.

## The ruling's premise, measured rather than inherited

The ruling asserts that API-key writes audit as the owner today. A note
that misdescribed the enforcement would recreate the very defect this
card closes, so the assertion was measured first. All readings against
`origin/main` at base `e3b3cdd`, taken 2026-09-20T11:05–11:15Z.

| reading | result |
|---|---|
| the one seam that produces an agent principal |
`packages/core/src/security/assemble-execution-context.ts#entryFields` —
`const agent = !anonymous && oauth?.clientId ? oauth : undefined`,
consumed by `principalKind`, `onBehalfOf` and `performedBy` |
| `principalKind` / `onBehalfOf` / `performedBy` in
`packages/core/src/security/api-key.ts` | **0 / 0 / 0** — lit control:
`userId` reads **6** in the same file |
| the same three in
`packages/core/src/security/resolve-authz-context.ts` | **0 / 0 / 0** —
lit control: `userId` reads **48** in the same file |
| the same three in `packages/runtime/src/security/api-key.ts` | **0 / 0
/ 0** (a 25-line re-export module) |
| which doors honour an API key, and what each hands the assembler |
**all three** that run `resolve-authz-context.ts#resolveAuthzContext`.
REST and MCP **stdio** pass `oauth: undefined` **by construction**
(`rest-server.ts`, `mcp/src/plugin.ts#resolveStdioExecutionContext`);
the runtime / MCP **HTTP** dispatcher excludes keys with a **guard**,
`resolve-execution-context.ts#extractJwtBearer`, refusing an
`osk_`-prefixed or non-JWT bearer |
| repo-wide non-test writers of `performedBy` | the MCP/OAuth seam, the
spec + hook declarations of the field, and the audit writer that reads
it. No API-key path |

⇒ an API-key caller falls through `agent ? 'agent' : anonymous ? 'guest'
: 'human'` to `human`, carries no `onBehalfOf` and no `performedBy`, and
its `sys_audit_log` row is the owner's own. **The measurement agrees
with the ruling's premise**, so the narrowing describes the enforcement
rather than changing it.

## Was there a dangling D6 sentence?

No. The clause 「explain (D6) reports both sides of the intersection」
lives *inside* rule 4, so the narrowing carries it. Measured on the ADR:
`attribution` occurs once in the whole file (rule 4) and `both sides`
once (its second line). The companion `docs/design/permission-model.md`
does not restate the rule at all — `performed_by` / `performedBy` /
`attribution` / `every write` read **0** there, against a lit control of
**14** for `agent`. The generated `content/docs/references/**` tables
carry the field's own `.describe()` text, which already says 「Set only
at the /mcp OAuth door … absent everywhere else」 — already narrow,
nothing to correct.

## Gates

Derived in this worktree from the real change set and reconciled with
the run log:

```
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack     # 18 commands
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RAN_FILE
  Run reconciliation — 18 derived, 18 run, 0 NOT-MEASURED, 0 UNRUN.
  EXIT CODES — all 18 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them.
```

All 18 exit 0, each captured to disk **before** any pipe.
`check-adr-links`, `check-adr-symbol-anchors` (+ both self-tests),
`check:adr-anchors`, `check:doc-authoring`, `check:nul-bytes`,
`check:pm-governed-merges`, `check:pm-prior-rulings`,
`check:comment-mask-corpus`, `check:cross-package-test-inputs`,
`check:driver-memory-census`, `check:refd-timer-probe`,
`check:watch-hint-literal`, `check:ci-filter-parity`,
`check:closing-keyword-parity` (+ self-test) are among them.
`check:doc-formula-expressions` first exited **3** — `PREREQUISITE NOT
MET`, the gate's own "nothing was measured" code — and was re-run to
exit 0 after `turbo run build --filter=@objectstack/formula
--filter=@objectstack/lint`, taken under the shared verify lock.

The three new symbol anchors in the note (`#entryFields`,
`#resolveApiKeyAdmission`, `#resolveAuthzContext`) resolve as
`declaration` class; no line-number anchor was introduced.

## 维护者速读(草稿)

**改了什么。** 只动一份决策记录(ADR-0090),一个文件,47 行增、2
行删。一句原本写着「**每一次**写入都要记下『谁代谁干的』」的规则,被收窄成「**代理主体**的每一次写入」;同一节末尾新增一段带日期的说明,写明
**API key 不在这条规则里,以及为什么**。⛔ 没有改任何代码,平台行为一行都没变。

**为什么改。** 平台里只有一个地方会把调用方判成「AI 代理」:MCP 的 OAuth 门。**三个门都认 API
key**,但没有一个会把它变成代理:REST 与 MCP stdio 结构上就不传 OAuth 凭据,MCP HTTP
门则靠一道两行的**守卫**挡住 `osk_` 开头的
bearer。三条路都只认出**钥匙的主人**,所以审计日志记的就是主人本人——这一点本轮在源码上实测过,与裁定的前提一致。于是规则写的是「每一次」,实现做的是「只有代理那一次」,这就是**声明面与执行面对不上**。维护者已裁
A(API key 是主人的**凭证**,不是第二个「谁」),裁定里同时明写:只维持现状而不改那句话,等于新留一条「说的和做的不一致」。这份
PR 就是把那句话改对,并把理由记在案。

**风险与代价(含回滚)。** 风险低:纯文档,无运行时、无 API、无数据结构变化,不发布任何 npm
包。真正的代价是**语义上的**:这条规则从此明确**不**覆盖 API
key。将来若出现合规要求「要分得清是人按的还是钥匙跑的」,那是一张新卡、新决策,而不是重读这一条——这一点也写进了 Note 里。回滚是一次
`git revert`,零迁移、零数据影响。

**席位意见。**

**你要做的(一个动作)。** 读一遍 D10 rule 4 那六行和它后面那段带日期的
Note,回「同意」或指出要改的措辞。这是受管面(`docs/adr/**`,Tier H),在你点头之前它会一直停在 draft。

## Acceptance notes

- **`skip-changeset` is owed and was deliberately NOT applied.** This
diff publishes nothing (`docs/adr/**`), so `Check Changeset` needs that
label; the dispatch forbids this executor from touching any label on a
governed-surface PR, so the label is left to the seat. Until it is
applied, `Check Changeset` is expected red and that red is not a finding
about this diff.
- **Noted, not filed — D10's 2026-07 Status blockquote still lists 「the
agent audit-provenance gap」 as an open follow-up.** That gap is the one
objectstack-ai#17022 covered, and that card is `closed`/`completed`
(2026-09-16T06:41Z). This is a stale pointer in prose, not a
reproducible defect, not a contract violation and not a
metadata-authoring trap, so it is not one of the three filing classes.
Carrier: **objectstack-ai#18374**, which already owns the residual close-out in the
same D10 area.
- **Seat assumption 2 re-measured wider than dispatched.** The dispatch
named four PRs; all **36** open PRs in the repo were read at
2026-09-20T11:10Z (`GET /pulls/{n}/files`). Exactly one touches
`docs/adr/**` — objectstack-ai#18985, on ADR-0089 and ADR-0137, disjoint files. Zero
open PRs hold ADR-0090. One caveat recorded rather than hidden: PR
objectstack-ai#17076 (`chore: version packages`) has more than 200 files; pages 1 and
2 were read (200 files, zero `docs/adr/` hits) and the tail was not
enumerated.
- **A small line drift in the dispatch's measurement table, reported as
instructed.** The card body cites the agent channel at `:293` consumed
at `:316`/`:317`; on `e3b3cdd` those are `:294`, `:317`, `:318` — the
file gained the `performedBy` line from objectstack-ai#18371. The rule 4 sentence
itself was anchored on its text and matched verbatim, at line 391 as the
seat read it.

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…s reopen condition (objectstack-ai#19653)

Fixes objectstack-ai#18374

## 维护者速读(草稿)

**改了什么** —— ADR-0090 D10 的 `agent` 规则 4 要求每次 agent
写入记三样:谁干的、代谁干的、**哪一次运行**。前两样已在产,第三样没有。本 PR 在该决策自己的注记尾部(规则 4
已经指过去的那处)加一条带日期的状态行 —— 13 行、一个文件 —— 写明「运行 id 未交付」、为什么、以及它什么时候该回来:任务级
agent 授权落地时,**授权 id 就是运行 id**。⛔ 规则 4 自己那句话一个字没动。

**为什么改** —— 执行裁决评论 `5749155184`(总监席 batch objectstack-ai#195 item 2,letter
**C**,维护者「同意」2026-09-20)。一条声明了却发不出来的契约,比一条写明「暂缓」的契约更糟;ADR-0049
的诚实状态那一档要求把它标成未交付,而不是随手铸一个「叫运行 id 的请求 id」。

**风险与代价(含回滚)** —— 纯文档:零运行时、零 spec、零发布面。回滚 = revert
这一个提交。真正的代价是未来读者会看到一条明写「未交付」的规则 —— 这正是目的,而不是副作用。

**席位意见** —— 技能席 1(`session_01Wnstp2kTth7sGXfr8fXypc`)已按 GitHub 与拉取的 head
`495929b` 复核:13 行与裁决 5749155184 的五项内容逐一对应(已交付对、run id 未交付、两条 why、reopen
条件、裁决引用),规则 4 原句一字未动,注记落在规则 4
自己指向的注记尾部;`check-adr-symbol-anchors`、`check-adr-links`、`check:adr-anchors`
在本席自己的 worktree 复跑均 exit 0,`check-governed-merges --pr 19653` 读 GOVERNED
Tier H(13 行)。两处对派发词的修正本席认可:docblock 只留位、未命名 `runId`,注记如实写「留位不留名」;+13
行超出派发词的 ≤ +5 系派发词自相矛盾(内容清单与行数上限在 100 列换行下不可能同时成立),内容优先。建议合并;`Fixes
objectstack-ai#18374` 合入即关卡,`pm:dispatched` 由本席在合入时摘。

**你要做的(一个动作)** —— 读那 13 行,认可就手工合并。`docs/adr/**` 是 Tier H 受管面,本 PR 保持
draft,⛔ 没有 AI 席位可以代你合、代你入队或给它挂 auto-merge。

Clause-②: no

## What landed

One dated status note in
`docs/adr/0090-permission-model-v2-concept-convergence.md` — **13 lines
added, 0 removed, 1 file**. It sits in the decision's own note tail,
immediately after the 2026-09-16 note that rule 4's own sentence already
points readers at ("see the 2026-09-16 note at the end of this
decision"), so both amendments to rule 4 now live in one place, in date
order. Rule 4's sentence is byte-unchanged.

The note states, in the ruling's order: `performed_by` and
`on_behalf_of` ship (objectstack-ai#18371); no run id is stamped and none is pending;
why (no agent-run concept on the request path — the MCP door is
explicitly stateless — so no server-constructed identifier grouping one
agent task exists to carry, and the two correlation ids that do travel
that path, `traceId` from the client's `traceparent` and `requestId`
preferring the client's `X-Request-Id`, are caller-controlled, which the
envelope refuses in writing); the reopen condition (task-scoped,
time-boxed agent grants land ⇒ the grant id is the run id ⇒ a successor
wires `performedBy.runId`); and the ruling reference in the file's own
`Ruling:` form.

## Two measured corrections to the dispatch's premises

**The shipped docblock reserves the ROOM, not the NAME.**
`packages/spec/src/kernel/execution-context.zod.ts#performedBy` reads
verbatim: "A one-key object rather than a bare string so the API-key
door (objectstack-ai#18335, blocked on this carrier) can name its own identifier as a
sibling key if it is ruled an agent, without re-shaping a field that
already shipped." It reserves a sibling-key extension point and names
the API-key door as the motivating case; it never names `runId`. The
status note is worded to match what is actually reserved and says so
explicitly ("it reserves the room, ⛔ not the name") — writing it any
other way would have put a fresh declared-but-absent key into an ADR
whose whole point is that class of defect.

**The line budget could not hold the enumerated content.** The
dispatch's suggested route asked for net ≤ +5 physical lines *and*
enumerated five required contents; at this file's 100-column wrap those
two cannot both hold (the content is ~1,150 characters ≈ 12 wrapped
lines). Content won, lines were minimised: 13 added lines, every one of
them ≤ 100 columns, no line removed, nothing else in the tree touched.

## Gates

Derived mechanically, not recalled: `node scripts/pm/dispatch-gates.mjs
--repo objectstack-ai/objectstack --commands` (no paths — the script
took its own change set from the merge base), 19 families. All 19 run,
all exit 0, reconciled with `--ran`:

`✓ dispatch-gates --ran: 19 derived famil(ies) accounted for — 19 run, 0
NOT-MEASURED (a DERIVED zero — all 19 recorded an exit code and none of
them is 3).`

Named readings from that set:

- `node scripts/check-adr-symbol-anchors.mjs` :: exit 0 — `✅
check-adr-symbol-anchors: 2121 anchors across 140 records resolve`
- `pnpm check:doc-authoring` :: exit 0
- `pnpm check:adr-anchors` :: exit 0
- `pnpm check:pm-governed-merges` :: exit 0
- `pnpm check:nul-bytes` :: exit 0 (plus a hand scan of the inserted
text for control bytes: no match)

One family needed a prerequisite: `pnpm --filter @objectstack/lint run
check:doc-formula-expressions` first exited **3** — its own text says
"Nothing was measured: this gate exited before running a single check",
naming two unbuilt workspace packages. After `pnpm exec turbo run build
--filter=@objectstack/formula --filter=@objectstack/lint` (through
`scripts/pm/os-verify-lock.sh`, `VERDICT command-exit 0`) it re-ran at
**exit 0**. The exit-3 reading is recorded as NOT MEASURED, ⛔ not as a
failure.

Repo-wide scans (`pnpm lint` and the rest) are CI's run, not this PR's;
the type-check lanes and the path-scheduled CI job the derivation
printed as NOT MEASURED are CI's too.

## Reverse verification — the new citation is actually checked

The note cites
`packages/spec/src/kernel/execution-context.zod.ts#performedBy`, and
`check-adr-symbol-anchors` resolves that spelling. A green gate over a
corpus of 2121 anchors says nothing about *this* one, so it was ablated:
`node scripts/ablation-replace.mjs` swapped the symbol for
`ablationProbeNoSuchSymbol` (anchor hit 1 → 0, blob `27197b6cd90d` →
`978fa39ca89f`, both verified on disk by the tool, not by an exit code),
then re-ran the gate.

Predicted direction: turns red naming this line. Observed:

```
❌ check-adr-symbol-anchors: 1 finding(s) across 140 records.
  [unresolved-symbol] docs/adr/0090-permission-model-v2-concept-convergence.md:455  `packages/spec/src/kernel/execution-context.zod.ts#ablationProbeNoSuchSymbol`
      `ablationProbeNoSuchSymbol` has no declaration site or string-literal token in `packages/spec/src/kernel/execution-context.zod.ts`
```

Restore leg: `ablation-replace: ok restored: blob == HEAD (27197b6)
and 'git diff HEAD' is empty`. Both legs ran from the committed state;
no probe file survives. (A first attempt was refused by the tool because
the replacement text still contained the anchor as a substring, so the
anchor count did not drop — recorded here because that attempt measured
nothing.)

## Changeset — `skip-changeset`, measured

`docs/adr/**` publishes nothing. Measured rather than assumed: of 83
tracked `package.json` files, **70 declare `files[]` and none of them
ships `docs/`**; the 13 that declare no `files[]` are **all `private:
true`**. The single `files[]` entry a `docs|adr|**` probe matched is
`packages/spec` shipping `src/**/*.zod.ts`, which is not a docs path.

## Landing posture — Tier H, stays draft

`docs/adr/**` is Tier H (人合) on the `GOVERNED_SURFACES` register in
`scripts/pm/check-governed-merges.mjs`. This PR is opened draft and
stays draft: the maintainer merges it by hand. ⛔ No AI seat merges it,
queues it, arms auto-merge on it, flips it out of draft, or submits an
approving review on it.

## Acceptance notes

- The zero-commit remote branch
`claude/issue-18374-run-id-entry-carrier` (`564ac2f170`, the
`domain:spec` seat's write-path probe) is still on the remote. It is a
**stale marker, not in-flight work**. Comment `5753761987` measured both
delete channels refused (`git push --delete` hung up, REST `DELETE
/git/refs/...` answered 403), so this run did ⛔ not re-attempt the
delete.
- Observation only, ⛔ no card filed: the 2026-07 status blockquote at
the top of D10 tracks "the agent audit-provenance gap" as a follow-up
with **no tracker number beside it**, unlike the `objectstack-ai#2849` in the same
sentence. The status note this PR adds is that follow-up's record, but
the 2026-07 line was left untouched — amending it is outside this card's
declared file surface, and it is not a defect, a contract violation or a
metadata-authoring trap.
- Concurrency re-checked at this branch's base: all 18 open PRs' file
lists read; none touches
`docs/adr/0090-permission-model-v2-concept-convergence.md`.
`origin/main` had not moved from `80ca0b1c88`, so no merge was owed.


---
_Generated by [Claude Code](https://claude.ai/code)_

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

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

2 participants