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 pinpackages/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 b57671edc1166d49be40993000894128b3a63b44packageMentionDocs.

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/specfiles[] 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

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