Repository navigation
feat(spec,runtime,plugin-audit): an action run through MCP leaves an audit row naming its surface - #28171
Conversation
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…es; changeset Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…tion-audit-surface Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…l comparison Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
Read the audit row's options argument without a cast so the tenant-audit census sees the system context, recount the census page, pin the new engine double, bound the test double's find, copy the new durability callee into the swallow census, and drop the issue id from the surface key's customer-facing description. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
Resolves sys_audit_log's action select: main labels every option (#27855), and this branch adds action_invocation, so the new value joins main's labelled list as { label: 'Action Invocation', value: 'action_invocation' }, with the en bundle's option label to match. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…n_invocation main's #27855 pin lists every stored value of sys_audit_log.action. This branch adds action_invocation writer-first, so the pin names it; no existing value moves. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
|
domain:spec#4 · marchtian · session_017ihL2fi9zfJhUTubyJYb6V 维护者速读(席位终稿 · head
|
📓 Docs Drift CheckThis PR changes 4 package(s): 53 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 15 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 182 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin dfa78b9d187795ba7ad53ce7d22f6671701b41a8 && git checkout dfa78b9d187795ba7ad53ce7d22f6671701b41a8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a3d24c991f2d793a1600e7f25fb0d5f577ed2fff 1d4e697f863b87ad87999502f43e306c3095a2bb && git checkout -B drift-repro a3d24c991f2d793a1600e7f25fb0d5f577ed2fff && git merge --no-ff 1d4e697f863b87ad87999502f43e306c3095a2bb
node scripts/docs-audit/affected-docs.mjs --json a3d24c991f2d793a1600e7f25fb0d5f577ed2fff
|
|
domain:spec#4 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz Contract-review-claim: |
|
domain:spec#4 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz Contract reviewServed-tier: Inputs: card #27908 (body, triage 6514607572, claim 6521029979, os-dev-report 6526091689 with its seven deviations and two out-of-scope findings), ruling 6513869839 on #27681 (letter B, parameter 3) as the card and claim quote it, the seat's ACCEPT record 6526530153, the PR body, the 30-file diff against merge base 9f13ee8, main's own movement since that base (462 files, three shared with this diff: action-execution.ts where main added the identity-route key refusal in ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentScenario: an administrator reading
Verdict on ④: no must-fix; two should-fixes. Implemented-by: VERDICT: PASS |
|
domain:spec#4 · marchtian · session_017ihL2fi9zfJhUTubyJYb6V Seat note on PASS 6537892848 · 2026-10-31T02:44Z · head
|
|
domain:spec#4 · marchtian · session_017ihL2fi9zfJhUTubyJYb6V Landing stroke · 2026-10-31T05:07Z · head
|
🤖 Generated with Claude Code
https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
Fixes #27908
Clause-②: yes (widening)
Metadata-protocol: added
维护者速读
run_action/resume_run)调用业务动作,审计日志sys_audit_log都多一行action_invocation,metadata.surface写明rest还是mcp;agent 代人调用时同一行还带performed_by/on_behalf_of。四个分支(脚本、api 转发、flow、声明式更新)都写,成功和分派后被拒都写。console.info,其余三个分支什么都不留。error日志,动作本身的结果不变。回滚即回退本 PR,已写入的审计行留在账本里。packages/spec/src/kernel/execution-context.zod.ts新键surface(取值rest/mcp);②packages/runtime/src/action-invocation-audit.ts(门如何盖上自己的 surface、为何 api 转发不需要额外的头);③packages/plugins/plugin-audit/src/action-invocation-audit.ts(审计行的形状)。pnpm --filter @objectstack/runtime exec vitest run src/action-invocation-audit.test.ts,预期Tests 24 passed:四个分支 × 两个门各写一行,伪造的转发头和上游surface都改不了记录的门。ExecutionContext.surface及其取值rest/mcp(连同sys_audit_log.action新值action_invocation)。Independent review: none run (quota); the PR waits for the seat's contract-review hand-off.
What changes
The key —
ExecutionContext.surface(@objectstack/spec).z.enum(['rest', 'mcp']).optional(), with theperformedBydocblock discipline (ADR-0090 D10 rule 4): set only by the two action doors, from the door's own identity, on the envelope of the invocation they record; never client-supplied; attribution only (no middleware reads it, never an authorization subject, scope or gate). The enum is closed to the doors that invoke an action today:rest(RESTPOST /actions/:object/:action) andmcp(the/mcpbridge'srun_action/resume_run). No other door invokes a business action: flow action nodes and the declarative endpoint executor do not route through either door (measured:invokeBusinessActionhas one caller,domains/mcp.ts). The maintainer approves the key name and its values in this PR.@objectstack/corelists it as a non-entry field (identity resolution does not produce it; the same credential reaches both doors).The doors (
@objectstack/runtime).action-invocation-audit.tsholds the door half:withActionSurface(ec, surface)(a COPY of the caller's envelope with the surface overwritten, so the request's own envelope is unchanged and nothing upstream can move the value) andrecordActionInvocation, which resolves theauditservice slot and hands it one event. Each door's post-gate dispatch is split into its own function, code unchanged, so the door can write the row for whatever that dispatch answers:handleActionsRequest→dispatchAdmittedRestAction; the row reads the answered status (anderror.code).run_action:invokeBusinessAction→dispatchAdmittedBusinessAction; the row reads the thrown error throughresolveThrownHttpError.resume_run:resumeActionRun→continueAdmittedRun(steps 4–6); the row names the admitting flow action and carries therun_id.Which calls write a row, and why. Every call that cleared its door's admission gates and reached its branch: the branch's success, and every refusal from there on (the subject-record verdict, a declarative write the data plane refused, an api target's non-2xx, the api loop refusal, a flow run that failed, a body that threw, "no handler registered"). A call refused by an admission gate (not found, the
sys_*/ai.exposedgates, ADR-0066 D4requiredPermissions, the ADR-0126 activation switch, the param contract, the confirmation gate, and forresume_runsteps 1–3) ran nothing and writes no row: the ruling asks to tell an agent's run from a click, and those calls ran nothing.The row (
@objectstack/plugin-audit).createActionInvocationAuditWriterjoinscreateAuthEventAuditSinkon theauditslot (recordActionInvocation). It fitssys_audit_logwith no new field:action: 'action_invocation'(a new value of the existingactionselect, added writer-first;sys-audit-log-retired-actions.test.tsnames its writer),user_id/actor/tenant_idfrom the envelope by the CRUD mirror's rules,object_name+record_idfor the subject (so the ledger's record read gate judges it), no snapshots and no params, andmetadata={ surface, action_name, branch, outcome, status?, error_code?, run_id?, performed_by?, on_behalf_of? }. A lost row is reported once per cause aterror(persistActionInvocationAuditRowis registered inDURABILITY_CRITICAL_CALLEES). The[action-audit]log line is kept and is not the row.The api forward. The row on the
apibranch is written by the door after the in-process forward answers, from the door's own envelope, which the target's re-authentication never replaces; so it names the true door with no surface header on the forward (pinned: the forwarded request carries none, and the row saysmcp). See Acceptance notes for why no trusted forward header was added.Tests
packages/runtime/src/action-invocation-audit.test.ts(24): every branch × both doors (REST throughHttpDispatcher.handleActions, MCP through the realbuildMcpBridge) hands the slot exactly one event with the rightsurface,branch,outcome; a refusal after dispatch on every branch × both doors recordsfailedwith the door's status and code; the subject-record 404 writes one failed row per door; a D4-gated call writes none; the forged-header case (an outside REST request carrying the forward mark, anx-objectstack-action-surface: mcpheader and an upstreamsurface: 'mcp'still recordsrest; an MCP call arriving assurface: 'rest'still recordsmcp); the api row after the forward; the request envelope left unchanged; the agent'sperformedBybeside the surface;resume_runsuccess and refusal rows.packages/plugins/plugin-audit/src/action-invocation-audit.test.ts(7): through a real ObjectQL engine, a click and an agent's call of the same action land as two rows that differ bysurfaceandperformed_by; a refusal's status/code and a resume's run id; object-less actions; no guessed surface; theactor-less ledger; the once-per-causeerrorreport with no throw.sys-audit-log-retired-actions.test.ts:action_invocationadded to the writer inventory.assemble-execution-context.test.ts:surfaceadded to the non-entry mirror.Ablation (one-shot,
scripts/ablation-replace.mjs, mutation proven on disk, restored by blob hash):await sink.recordActionInvocation(event);→ removed ⇒action-invocation-audit.test.ts20 failed | 4 passed (the 4 are the no-row gate case and the helper cases); restored blobd014ccafe5bb.await persistActionInvocationAuditRow(engine, row);→ removed ⇒ 3 failed | 4 passed (every read-back case); restored blob9d2076906454.Acceptance notes
added, not the claim'snone(the order asked to checkpr-automation.yml's WHICH LEVEL table and follow it). The table's gate (check-changeset-no-major.mjs,none-baseline-moved) refusesnonewhen anauthorable-surface/shard moves, and this key movespackages/spec/authorable-surface/kernel.json(kernel/ExecutionContext:surface, regenerated by the build — the shard lists everyExecutionContextkey). So the declaration isaddedand every moved package's changeset isminor(spec, core, runtime, plugin-audit).x-objectstack-action-forward, and its only effect is the loop refusal — so a surface header "honoured with the same trust" would be honoured from any caller, letting an agent's call recordrest; (3) a sound carry needs a new in-process credential, and each face that resolves an envelope (resolveRequestExecutionContexthas seven door callers) would have to honour it; (4) no far-side writer would read it: the CRUD mirror's hook provenance carriesflowRunId/attributedUserId/performedByClientIdonly, and thesys_*identity targets write through better-auth's adapter under its own system context. Carrying the surface onto the CRUD rows of the target's writes is a separate change (HookContext provenance + objectql), not made here.sys_audit_loggains an enum VALUE, not a field.tenant_idis the caller's active organization (ExecutionContext.tenantId); the CRUD mirror prefers the record's own organization, which this row does not read.apiaction forward reaches its target with the bearer the dispatcher honours only on/mcppaths (acceptOAuthAccessTokenpath match,http-dispatcher.ts), so the target would resolve the caller as anonymous. Out of this card; reported to the seat.Verification
Local gates and tests (head and commands in the os-dev-report comment on #27908)
pnpm build(whole workspace, under the verify lock): exit 0@objectstack/spec check:generated: 14/14 up to dateruntime/src/action-invocation-audit.test.ts24 / 24 on the final headnode scripts/pm/dispatch-gates.mjs --commands: 131 derived commands, each run with its exit code recorded before any pipe;--ranreconciliation: 131 derived, 131 run, 0 NOT-MEASURED, 0 UNRUN, every exit 0check-tenant-audit-census(the new write site; the options cast is dropped so the census reads its system context, and the page is recounted 251 to 252),check:engine-double-contract(ledger--write),check:objectql-double-limit(the test double now applieslimit),check:swallow-census-controls(the new durability callee copied intoWRITE_SHAPED_CALLEES),check:doc-authoring(issue id dropped from the key's.describe()). Three more first answered exit 3 (not measured: no build or no lock turn) and were measured green afterpnpm build:check:skill-examples,check:dual-build-cjs-loads,check:type-check-debt.check-adr-0087-registration.mjs --base origin/main: exit 0 (no declared-breaking changeset)Generated by Claude Code