docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution - #14035

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose
Sep 1, 2026
Merged

docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution#14035
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14011

A prose-only correction. The shipped runtime behaviour is right and is untouched — the sentence describing it was not.

The stale sentence

packages/spec/src/contracts/automation-service.ts, documenting AutomationContext.flowName (published as dist/contracts/index.d.ts, line 3033 on 17.1.0 / line 3000 on this branch's 17.2.0 build):

a runAs:'system' run resolves no user, so resolveRunDataContext labels its data operations svc:flow:FLOWNAME on ExecutionContext.actor … instead of leaving the audit row unattributed

That reads as "system elevation costs you the operator in the audit trail."

What actually ships — re-verified against origin/main, not taken from the card

The card's reading was measured on the 17.1.0 artifact; every claim below was re-derived from the source in this checkout at 62a137b.

  • packages/services/service-automation/src/runtime-identity.tsresolveRunDataContext, runAs:'system' branch: { isSystem: true, actor: 'svc:flow:' + flowName, ...(context.userId ? { userId: context.userId } : {}), ...(context.tenantId ? { tenantId } : {}), positions: [], permissions: [] }. The operator is carried through unchanged (Automation create_record under runAs:'system' inserts rows with owner_id/organization_id/created_by all NULL — records born untouchable even by admin #5494) — the comment on that line says so in as many words: "elevation is not anonymity … isSystem alone decides authorization, while the user drives the platform's attribution stamps."
  • packages/objectql/src/plugin.tssys_stamp_audit_insert / sys_stamp_audit_update stamp created_by / updated_by under if (session?.userId). There is no isSystem test anywhere on that path.
  • packages/plugins/plugin-audit/src/audit-writers.tswriteAudit computes userId = sess.userId ?? provenance.attributedUserId and actorLabel = userId ?? sess.actor. The svc:flow: label is the fallback, in that order, with no isSystem gate.

So the svc:flow: labelling the sentence described is real — but it is the user-less case (a schedule, or a runAs:'system' flow fired by a write that itself carried no user). The sentence generalised it to every runAs:'system' run.

What changed

1. The prose (packages/spec/src/contracts/automation-service.ts). The flowName JSDoc now states what is true of both cases: elevation decides AUTHORIZATION and leaves ATTRIBUTION alone; the svc:flow: actor label is the fallback a genuinely user-less run lands on, where ADR-0118 D1 keeps the user column null rather than inventing a sentinel.

2. A sweep for the same wrong concept. Searched packages/spec/src, every package's JSDoc, and content/docs/ for the family — resolves no user, unattributed, user-less, Unknown user, elevation/anonymity, and every prose mention of runAs: 'system' and svc:flow:. Exactly one site carried the wrong generalisation, the one fixed here. Everything else that mentions a user-less run is already scoped to the genuinely user-less case and was deliberately left alone (for instance content/docs/permissions/system-context.mdx line 200, "a user-less system write stamps nothing", and service-automation/src/engine.ts line 3436, "for runs that resolve no user" — both correct as written).

3. A line between the prose and the code that rings.packages/services/service-automation/src/runas-attribution-contract.test.ts asserts the invariant the prose now states, at the end of the chain — the session envelope the audit writers actually read — rather than at resolveRunDataContext's return shape, which builtin/crud-runas.test.ts already pins:

  • an elevated, user-triggered run updates a row created by a different user, and updated_by moves to the triggering operator — the same value the identical write produces on the plain user path, with created_by left on the creator;
  • a genuinely user-less run (schedule shape) creates a row whose user column stays NULL, and whose hook-visible session.actor is svc:flow:night_sweep.

If the code ever becomes what the old prose described, this file goes red instead of the next reader having to re-measure.

Reverse verification — and what it caught

Direction predicted before running: the operator limb goes RED, the user-less limb stays GREEN (it has no user to strip).

Ablation A — deleted the userId carry-through from resolveRunDataContext's system branch (i.e. made the code match the old prose). The first run reddened only the hook-session assertion; the updated_by column assertion passed under mutation, because the row had been seeded by the operator and already carried that value from its own insert. That assertion was measuring the insert, not the elevated update. Fixed by seeding the rows as a different user (usr_creator), so the column has to move for the assertion to pass. Re-ablated:

AssertionError: the elevated run must stamp the triggering operator: expected 'usr_creator' to be 'usr_operator'
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)

Ablation B — replaced the svc:flow: label with a constant, to check the second limb is live too:

AssertionError: the flow label names WHICH automation wrote (ADR-0014 D2): expected 'svc:ABLATED' to be 'svc:flow:elevated_touch'
AssertionError: the svc:flow: label is the fallback attribution: expected 'svc:ABLATED' to be 'svc:flow:night_sweep'
Test Files 1 failed (1)
Tests 2 failed (2)

Both ablations mutated a committed tree, proved the mutation on disk by grep counts plus a git hash-object comparison against the HEAD blob, carried an absolute-path trap … EXIT INT TERM, and restored to the byte-identical HEAD blob (git diff HEAD empty) before the next measurement. No rebuild step was needed for either: runtime-identity.ts is reached from builtin/crud-nodes.ts by a relative source import inside its own package, and this package's vitest config aliases only @objectstack/platform-objects — nothing routes its own modules through exports/dist.

Verification

All readings below are from aca37be, the branch head; the gate union was run after the final commit.

CheckResult
@objectstack/service-automation full suite93 files / 1099 tests passed — no existing behaviour assertion moved
@objectstack/spec full suite447 files / 11991 tests passed
@objectstack/spectsc --noEmit0 errors
@objectstack/service-automationtsc --noEmitexactly the 3 ledgered TS2341 in nested-region-parity.test.ts; 0 from the new file, and --listFiles confirms the new test file is in that program
pnpm lint (repo-wide eslint . --no-inline-config)exit 0, no findings
Derived gate family (scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands)48 commands, 45 green

The three non-green results are all exit 3 / PREREQUISITE NOT MET — nothing measured, not findings:

  • check:type-check-debt and check:dual-build-cjs-loads and scripts/check-dev-prereqs.mjs all refuse without a fully built workspace closure. CI builds it before those steps, exactly as lint.yml does. The substance the ratchet guards was measured directly instead, in the tsc --noEmit row above: the new test file is in the program and contributes zero errors, so the @objectstack/service-automation entry (errors: 3) does not move.
  • scripts/check-test-completeness.mjs (exit 3) needs a saved turbo run test log it cannot produce itself, and scripts/pm/check-half-states.mjs (exit 3) needs repo-scoped egress this container does not have. Both declare that exit as NOT MEASURED.

packages/spec was rebuilt after the edit, and the corrected sentence is present in dist/contracts/index.d.ts (1 occurrence) with the stale one gone (0 occurrences).

Notes for the reviewer


Generated by Claude Code

…14011)
The published contract said a `runAs:'system'` run "resolves no user", so the
`svc:flow:<flowName>` actor label stands in for the audit row's attribution.
That reads as "system elevation costs you the operator in the audit trail" and
has not been true since #5494: `resolveRunDataContext` carries the triggering
user through unchanged, `isSystem` alone decides authorization, and the audit
writers record `session.userId ?? session.actor` with no `isSystem` gate. The
`svc:flow:` label is the FALLBACK for a genuinely user-less run (a schedule).
Runtime behaviour is untouched. `runas-attribution-contract.test.ts` pins both
limbs against the real ObjectQL stack so the next drift goes red on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s).

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

  • content/docs/releases/v15.mdx(via AutomationContext (symbol, a top-level interface))

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
  • 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 — 128 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 0d80862366f5181b5db75c5ba103c69db98df181packageMentionDocs.

Which tree this was computed on

This run read content/docs from c6078e63e62fafba017369a374d2836498d0139f — the merge of head aca37be257b3e88742e7b16ef84b61e779219f7c into base 0d80862366f5181b5db75c5ba103c69db98df181, 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 c6078e63e62fafba017369a374d2836498d0139f && git checkout c6078e63e62fafba017369a374d2836498d0139f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0d80862366f5181b5db75c5ba103c69db98df181 aca37be257b3e88742e7b16ef84b61e779219f7c && git checkout -B drift-repro 0d80862366f5181b5db75c5ba103c69db98df181 && git merge --no-ff aca37be257b3e88742e7b16ef84b61e779219f7c
node scripts/docs-audit/affected-docs.mjs --json 0d80862366f5181b5db75c5ba103c69db98df181

⚠️ 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 0d80862366f5181b5db75c5ba103c69db98df181 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Docs Drift Check 的两行都查过了,结论分开:一行是误报,一行是真的,已按守则立卡而非在此编辑。 本 PR 未触碰 content/docs/releases/,也不会去触碰。

它列的页结论
content/docs/releases/v15.mdx:863正确,无需动作。 原文是「Flow-type actions now receive the caller's identity as a real AutomationContextrunAs: 'user' flows evaluate RLS as the caller)」—— 讲的是 runAs: 'user',本身成立。它被列出只因为点名了 AutomationContext 这个符号,正是该 bot 宣称的 precision-first 行为在正常工作。
(bot 未列,我顺着同一个概念扫出来的)content/docs/releases/v17.mdx:2073真的有问题,已立 #14039

那条真的

v17.mdx:2072-2073#4366):

…and a runAs: 'system' flow's writes are audited as svc:flow:<flowName> instead of "Unknown user"

无限定,读起来就是所有 system 提权运行的通则 —— 也就是本 PR 正在契约散文里消灭的那个错概念。

同一个发布页v17.mdx:2860-2861#5494

runAs: 'system'create_record stamps all three ADR-0118 columns — organization, owner and creator are non-NULL.

两条各自都不假(一条讲 actor 标签、一条讲落库的三列),但并排读,靠前那条在靠后那条之前约 800 行,中间没有任何东西把它们连起来。front-to-back 读 v17 的人会先遇到无限定版本。

不建议直接改写 #4366 那条 —— 那会篡改它当时确实交付的东西。#14039 里给了两个保持记录诚实的选项(加限定+指引 / 依赖本 PR 修好的契约散文),并说明发布说明政策是发布流程的事,不是下游报告人的,所以我没有提 PR。

关于 dev 的扫描范围

派发词明令禁止编辑 content/docs/releases/,dev 遵守了。它的同族措辞扫描覆盖了 packages/spec/src、各包 JSDoc 与 content/docs/,报「exactly one site carried the wrong generalisation」。那个读数对本 PR 可改的范围是准的 —— v17 那条落在只读区,本来就不该出现在一个可改清单里。这不是漏扫,是边界。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给 spec 席位。 head aca37be 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:03、CI 01:10)。无冲突,无评审意见待处理。

保持 draft,我不转 ready 也不合 —— 这是本仓契约面的裁量权,不属于一个下游 PM 席位。我这边对本 PR 的驱动到此为止。

要落它需要知道的三件事,集中在这里免得翻楼:

  1. 运行时行为一个字没改,改的是描述它的散文加一份钉住不变式的测试。service-automation 93 文件 / 1099 测试全过,无既有断言移动。
  2. packages/spec patch 级 changeset —— 更正随发布的 .d.ts 出去。service-automation 不升版,它只多了一个测试文件。
  3. Docs Drift Check 的两行都查过了(见上方评论):v15.mdx 那行正确、无需动作;顺着同一概念另外扫到 v17.mdx:2073 确有问题,已按只读守则立卡不编辑v17 release notes: the #4366 entry states the svc:flow: audit label unqualified, and the #5494 entry later in the same page supersedes it — nothing connects them #14039)。本 PR 未触碰 content/docs/releases/

#14044#14010 半 B)不重叠:那张改 CLI/lint/runtime,本张只改 spec 契约散文与一个新测试。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 474242fSep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14011-runas-attribution-prose branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stale contract prose: AutomationContext.flowName says a runAs:'system' run "resolves no user", but #5494 made elevation carry the operator through

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution - #14035

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose
Sep 1, 2026
Merged

docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution#14035
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14011

A prose-only correction. The shipped runtime behaviour is right and is untouched — the sentence describing it was not.

The stale sentence

packages/spec/src/contracts/automation-service.ts, documenting AutomationContext.flowName (published as dist/contracts/index.d.ts, line 3033 on 17.1.0 / line 3000 on this branch's 17.2.0 build):

a runAs:'system' run resolves no user, so resolveRunDataContext labels its data operations svc:flow:FLOWNAME on ExecutionContext.actor … instead of leaving the audit row unattributed

That reads as "system elevation costs you the operator in the audit trail."

What actually ships — re-verified against origin/main, not taken from the card

The card's reading was measured on the 17.1.0 artifact; every claim below was re-derived from the source in this checkout at 62a137b.

  • packages/services/service-automation/src/runtime-identity.tsresolveRunDataContext, runAs:'system' branch: { isSystem: true, actor: 'svc:flow:' + flowName, ...(context.userId ? { userId: context.userId } : {}), ...(context.tenantId ? { tenantId } : {}), positions: [], permissions: [] }. The operator is carried through unchanged (Automation create_record under runAs:'system' inserts rows with owner_id/organization_id/created_by all NULL — records born untouchable even by admin #5494) — the comment on that line says so in as many words: "elevation is not anonymity … isSystem alone decides authorization, while the user drives the platform's attribution stamps."
  • packages/objectql/src/plugin.tssys_stamp_audit_insert / sys_stamp_audit_update stamp created_by / updated_by under if (session?.userId). There is no isSystem test anywhere on that path.
  • packages/plugins/plugin-audit/src/audit-writers.tswriteAudit computes userId = sess.userId ?? provenance.attributedUserId and actorLabel = userId ?? sess.actor. The svc:flow: label is the fallback, in that order, with no isSystem gate.

So the svc:flow: labelling the sentence described is real — but it is the user-less case (a schedule, or a runAs:'system' flow fired by a write that itself carried no user). The sentence generalised it to every runAs:'system' run.

What changed

1. The prose (packages/spec/src/contracts/automation-service.ts). The flowName JSDoc now states what is true of both cases: elevation decides AUTHORIZATION and leaves ATTRIBUTION alone; the svc:flow: actor label is the fallback a genuinely user-less run lands on, where ADR-0118 D1 keeps the user column null rather than inventing a sentinel.

2. A sweep for the same wrong concept. Searched packages/spec/src, every package's JSDoc, and content/docs/ for the family — resolves no user, unattributed, user-less, Unknown user, elevation/anonymity, and every prose mention of runAs: 'system' and svc:flow:. Exactly one site carried the wrong generalisation, the one fixed here. Everything else that mentions a user-less run is already scoped to the genuinely user-less case and was deliberately left alone (for instance content/docs/permissions/system-context.mdx line 200, "a user-less system write stamps nothing", and service-automation/src/engine.ts line 3436, "for runs that resolve no user" — both correct as written).

3. A line between the prose and the code that rings.packages/services/service-automation/src/runas-attribution-contract.test.ts asserts the invariant the prose now states, at the end of the chain — the session envelope the audit writers actually read — rather than at resolveRunDataContext's return shape, which builtin/crud-runas.test.ts already pins:

  • an elevated, user-triggered run updates a row created by a different user, and updated_by moves to the triggering operator — the same value the identical write produces on the plain user path, with created_by left on the creator;
  • a genuinely user-less run (schedule shape) creates a row whose user column stays NULL, and whose hook-visible session.actor is svc:flow:night_sweep.

If the code ever becomes what the old prose described, this file goes red instead of the next reader having to re-measure.

Reverse verification — and what it caught

Direction predicted before running: the operator limb goes RED, the user-less limb stays GREEN (it has no user to strip).

Ablation A — deleted the userId carry-through from resolveRunDataContext's system branch (i.e. made the code match the old prose). The first run reddened only the hook-session assertion; the updated_by column assertion passed under mutation, because the row had been seeded by the operator and already carried that value from its own insert. That assertion was measuring the insert, not the elevated update. Fixed by seeding the rows as a different user (usr_creator), so the column has to move for the assertion to pass. Re-ablated:

AssertionError: the elevated run must stamp the triggering operator: expected 'usr_creator' to be 'usr_operator'
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)

Ablation B — replaced the svc:flow: label with a constant, to check the second limb is live too:

AssertionError: the flow label names WHICH automation wrote (ADR-0014 D2): expected 'svc:ABLATED' to be 'svc:flow:elevated_touch'
AssertionError: the svc:flow: label is the fallback attribution: expected 'svc:ABLATED' to be 'svc:flow:night_sweep'
Test Files 1 failed (1)
Tests 2 failed (2)

Both ablations mutated a committed tree, proved the mutation on disk by grep counts plus a git hash-object comparison against the HEAD blob, carried an absolute-path trap … EXIT INT TERM, and restored to the byte-identical HEAD blob (git diff HEAD empty) before the next measurement. No rebuild step was needed for either: runtime-identity.ts is reached from builtin/crud-nodes.ts by a relative source import inside its own package, and this package's vitest config aliases only @objectstack/platform-objects — nothing routes its own modules through exports/dist.

Verification

All readings below are from aca37be, the branch head; the gate union was run after the final commit.

CheckResult
@objectstack/service-automation full suite93 files / 1099 tests passed — no existing behaviour assertion moved
@objectstack/spec full suite447 files / 11991 tests passed
@objectstack/spectsc --noEmit0 errors
@objectstack/service-automationtsc --noEmitexactly the 3 ledgered TS2341 in nested-region-parity.test.ts; 0 from the new file, and --listFiles confirms the new test file is in that program
pnpm lint (repo-wide eslint . --no-inline-config)exit 0, no findings
Derived gate family (scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands)48 commands, 45 green

The three non-green results are all exit 3 / PREREQUISITE NOT MET — nothing measured, not findings:

  • check:type-check-debt and check:dual-build-cjs-loads and scripts/check-dev-prereqs.mjs all refuse without a fully built workspace closure. CI builds it before those steps, exactly as lint.yml does. The substance the ratchet guards was measured directly instead, in the tsc --noEmit row above: the new test file is in the program and contributes zero errors, so the @objectstack/service-automation entry (errors: 3) does not move.
  • scripts/check-test-completeness.mjs (exit 3) needs a saved turbo run test log it cannot produce itself, and scripts/pm/check-half-states.mjs (exit 3) needs repo-scoped egress this container does not have. Both declare that exit as NOT MEASURED.

packages/spec was rebuilt after the edit, and the corrected sentence is present in dist/contracts/index.d.ts (1 occurrence) with the stale one gone (0 occurrences).

Notes for the reviewer


Generated by Claude Code

…14011)
The published contract said a `runAs:'system'` run "resolves no user", so the
`svc:flow:<flowName>` actor label stands in for the audit row's attribution.
That reads as "system elevation costs you the operator in the audit trail" and
has not been true since #5494: `resolveRunDataContext` carries the triggering
user through unchanged, `isSystem` alone decides authorization, and the audit
writers record `session.userId ?? session.actor` with no `isSystem` gate. The
`svc:flow:` label is the FALLBACK for a genuinely user-less run (a schedule).
Runtime behaviour is untouched. `runas-attribution-contract.test.ts` pins both
limbs against the real ObjectQL stack so the next drift goes red on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s).

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

  • content/docs/releases/v15.mdx(via AutomationContext (symbol, a top-level interface))

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
  • 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 — 128 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 0d80862366f5181b5db75c5ba103c69db98df181packageMentionDocs.

Which tree this was computed on

This run read content/docs from c6078e63e62fafba017369a374d2836498d0139f — the merge of head aca37be257b3e88742e7b16ef84b61e779219f7c into base 0d80862366f5181b5db75c5ba103c69db98df181, 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 c6078e63e62fafba017369a374d2836498d0139f && git checkout c6078e63e62fafba017369a374d2836498d0139f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0d80862366f5181b5db75c5ba103c69db98df181 aca37be257b3e88742e7b16ef84b61e779219f7c && git checkout -B drift-repro 0d80862366f5181b5db75c5ba103c69db98df181 && git merge --no-ff aca37be257b3e88742e7b16ef84b61e779219f7c
node scripts/docs-audit/affected-docs.mjs --json 0d80862366f5181b5db75c5ba103c69db98df181

⚠️ 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 0d80862366f5181b5db75c5ba103c69db98df181 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Docs Drift Check 的两行都查过了,结论分开:一行是误报,一行是真的,已按守则立卡而非在此编辑。 本 PR 未触碰 content/docs/releases/,也不会去触碰。

它列的页结论
content/docs/releases/v15.mdx:863正确,无需动作。 原文是「Flow-type actions now receive the caller's identity as a real AutomationContextrunAs: 'user' flows evaluate RLS as the caller)」—— 讲的是 runAs: 'user',本身成立。它被列出只因为点名了 AutomationContext 这个符号,正是该 bot 宣称的 precision-first 行为在正常工作。
(bot 未列,我顺着同一个概念扫出来的)content/docs/releases/v17.mdx:2073真的有问题,已立 #14039

那条真的

v17.mdx:2072-2073#4366):

…and a runAs: 'system' flow's writes are audited as svc:flow:<flowName> instead of "Unknown user"

无限定,读起来就是所有 system 提权运行的通则 —— 也就是本 PR 正在契约散文里消灭的那个错概念。

同一个发布页v17.mdx:2860-2861#5494

runAs: 'system'create_record stamps all three ADR-0118 columns — organization, owner and creator are non-NULL.

两条各自都不假(一条讲 actor 标签、一条讲落库的三列),但并排读,靠前那条在靠后那条之前约 800 行,中间没有任何东西把它们连起来。front-to-back 读 v17 的人会先遇到无限定版本。

不建议直接改写 #4366 那条 —— 那会篡改它当时确实交付的东西。#14039 里给了两个保持记录诚实的选项(加限定+指引 / 依赖本 PR 修好的契约散文),并说明发布说明政策是发布流程的事,不是下游报告人的,所以我没有提 PR。

关于 dev 的扫描范围

派发词明令禁止编辑 content/docs/releases/,dev 遵守了。它的同族措辞扫描覆盖了 packages/spec/src、各包 JSDoc 与 content/docs/,报「exactly one site carried the wrong generalisation」。那个读数对本 PR 可改的范围是准的 —— v17 那条落在只读区,本来就不该出现在一个可改清单里。这不是漏扫,是边界。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给 spec 席位。 head aca37be 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:03、CI 01:10)。无冲突,无评审意见待处理。

保持 draft,我不转 ready 也不合 —— 这是本仓契约面的裁量权,不属于一个下游 PM 席位。我这边对本 PR 的驱动到此为止。

要落它需要知道的三件事,集中在这里免得翻楼:

  1. 运行时行为一个字没改,改的是描述它的散文加一份钉住不变式的测试。service-automation 93 文件 / 1099 测试全过,无既有断言移动。
  2. packages/spec patch 级 changeset —— 更正随发布的 .d.ts 出去。service-automation 不升版,它只多了一个测试文件。
  3. Docs Drift Check 的两行都查过了(见上方评论):v15.mdx 那行正确、无需动作;顺着同一概念另外扫到 v17.mdx:2073 确有问题,已按只读守则立卡不编辑v17 release notes: the #4366 entry states the svc:flow: audit label unqualified, and the #5494 entry later in the same page supersedes it — nothing connects them #14039)。本 PR 未触碰 content/docs/releases/

#14044#14010 半 B)不重叠:那张改 CLI/lint/runtime,本张只改 spec 契约散文与一个新测试。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 474242fSep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14011-runas-attribution-prose branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stale contract prose: AutomationContext.flowName says a runAs:'system' run "resolves no user", but #5494 made elevation carry the operator through

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution - #14035

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose
Sep 1, 2026
Merged

docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution#14035
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14011

A prose-only correction. The shipped runtime behaviour is right and is untouched — the sentence describing it was not.

The stale sentence

packages/spec/src/contracts/automation-service.ts, documenting AutomationContext.flowName (published as dist/contracts/index.d.ts, line 3033 on 17.1.0 / line 3000 on this branch's 17.2.0 build):

a runAs:'system' run resolves no user, so resolveRunDataContext labels its data operations svc:flow:FLOWNAME on ExecutionContext.actor … instead of leaving the audit row unattributed

That reads as "system elevation costs you the operator in the audit trail."

What actually ships — re-verified against origin/main, not taken from the card

The card's reading was measured on the 17.1.0 artifact; every claim below was re-derived from the source in this checkout at 62a137b.

  • packages/services/service-automation/src/runtime-identity.tsresolveRunDataContext, runAs:'system' branch: { isSystem: true, actor: 'svc:flow:' + flowName, ...(context.userId ? { userId: context.userId } : {}), ...(context.tenantId ? { tenantId } : {}), positions: [], permissions: [] }. The operator is carried through unchanged (Automation create_record under runAs:'system' inserts rows with owner_id/organization_id/created_by all NULL — records born untouchable even by admin #5494) — the comment on that line says so in as many words: "elevation is not anonymity … isSystem alone decides authorization, while the user drives the platform's attribution stamps."
  • packages/objectql/src/plugin.tssys_stamp_audit_insert / sys_stamp_audit_update stamp created_by / updated_by under if (session?.userId). There is no isSystem test anywhere on that path.
  • packages/plugins/plugin-audit/src/audit-writers.tswriteAudit computes userId = sess.userId ?? provenance.attributedUserId and actorLabel = userId ?? sess.actor. The svc:flow: label is the fallback, in that order, with no isSystem gate.

So the svc:flow: labelling the sentence described is real — but it is the user-less case (a schedule, or a runAs:'system' flow fired by a write that itself carried no user). The sentence generalised it to every runAs:'system' run.

What changed

1. The prose (packages/spec/src/contracts/automation-service.ts). The flowName JSDoc now states what is true of both cases: elevation decides AUTHORIZATION and leaves ATTRIBUTION alone; the svc:flow: actor label is the fallback a genuinely user-less run lands on, where ADR-0118 D1 keeps the user column null rather than inventing a sentinel.

2. A sweep for the same wrong concept. Searched packages/spec/src, every package's JSDoc, and content/docs/ for the family — resolves no user, unattributed, user-less, Unknown user, elevation/anonymity, and every prose mention of runAs: 'system' and svc:flow:. Exactly one site carried the wrong generalisation, the one fixed here. Everything else that mentions a user-less run is already scoped to the genuinely user-less case and was deliberately left alone (for instance content/docs/permissions/system-context.mdx line 200, "a user-less system write stamps nothing", and service-automation/src/engine.ts line 3436, "for runs that resolve no user" — both correct as written).

3. A line between the prose and the code that rings.packages/services/service-automation/src/runas-attribution-contract.test.ts asserts the invariant the prose now states, at the end of the chain — the session envelope the audit writers actually read — rather than at resolveRunDataContext's return shape, which builtin/crud-runas.test.ts already pins:

  • an elevated, user-triggered run updates a row created by a different user, and updated_by moves to the triggering operator — the same value the identical write produces on the plain user path, with created_by left on the creator;
  • a genuinely user-less run (schedule shape) creates a row whose user column stays NULL, and whose hook-visible session.actor is svc:flow:night_sweep.

If the code ever becomes what the old prose described, this file goes red instead of the next reader having to re-measure.

Reverse verification — and what it caught

Direction predicted before running: the operator limb goes RED, the user-less limb stays GREEN (it has no user to strip).

Ablation A — deleted the userId carry-through from resolveRunDataContext's system branch (i.e. made the code match the old prose). The first run reddened only the hook-session assertion; the updated_by column assertion passed under mutation, because the row had been seeded by the operator and already carried that value from its own insert. That assertion was measuring the insert, not the elevated update. Fixed by seeding the rows as a different user (usr_creator), so the column has to move for the assertion to pass. Re-ablated:

AssertionError: the elevated run must stamp the triggering operator: expected 'usr_creator' to be 'usr_operator'
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)

Ablation B — replaced the svc:flow: label with a constant, to check the second limb is live too:

AssertionError: the flow label names WHICH automation wrote (ADR-0014 D2): expected 'svc:ABLATED' to be 'svc:flow:elevated_touch'
AssertionError: the svc:flow: label is the fallback attribution: expected 'svc:ABLATED' to be 'svc:flow:night_sweep'
Test Files 1 failed (1)
Tests 2 failed (2)

Both ablations mutated a committed tree, proved the mutation on disk by grep counts plus a git hash-object comparison against the HEAD blob, carried an absolute-path trap … EXIT INT TERM, and restored to the byte-identical HEAD blob (git diff HEAD empty) before the next measurement. No rebuild step was needed for either: runtime-identity.ts is reached from builtin/crud-nodes.ts by a relative source import inside its own package, and this package's vitest config aliases only @objectstack/platform-objects — nothing routes its own modules through exports/dist.

Verification

All readings below are from aca37be, the branch head; the gate union was run after the final commit.

CheckResult
@objectstack/service-automation full suite93 files / 1099 tests passed — no existing behaviour assertion moved
@objectstack/spec full suite447 files / 11991 tests passed
@objectstack/spectsc --noEmit0 errors
@objectstack/service-automationtsc --noEmitexactly the 3 ledgered TS2341 in nested-region-parity.test.ts; 0 from the new file, and --listFiles confirms the new test file is in that program
pnpm lint (repo-wide eslint . --no-inline-config)exit 0, no findings
Derived gate family (scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands)48 commands, 45 green

The three non-green results are all exit 3 / PREREQUISITE NOT MET — nothing measured, not findings:

  • check:type-check-debt and check:dual-build-cjs-loads and scripts/check-dev-prereqs.mjs all refuse without a fully built workspace closure. CI builds it before those steps, exactly as lint.yml does. The substance the ratchet guards was measured directly instead, in the tsc --noEmit row above: the new test file is in the program and contributes zero errors, so the @objectstack/service-automation entry (errors: 3) does not move.
  • scripts/check-test-completeness.mjs (exit 3) needs a saved turbo run test log it cannot produce itself, and scripts/pm/check-half-states.mjs (exit 3) needs repo-scoped egress this container does not have. Both declare that exit as NOT MEASURED.

packages/spec was rebuilt after the edit, and the corrected sentence is present in dist/contracts/index.d.ts (1 occurrence) with the stale one gone (0 occurrences).

Notes for the reviewer


Generated by Claude Code

…14011)
The published contract said a `runAs:'system'` run "resolves no user", so the
`svc:flow:<flowName>` actor label stands in for the audit row's attribution.
That reads as "system elevation costs you the operator in the audit trail" and
has not been true since #5494: `resolveRunDataContext` carries the triggering
user through unchanged, `isSystem` alone decides authorization, and the audit
writers record `session.userId ?? session.actor` with no `isSystem` gate. The
`svc:flow:` label is the FALLBACK for a genuinely user-less run (a schedule).
Runtime behaviour is untouched. `runas-attribution-contract.test.ts` pins both
limbs against the real ObjectQL stack so the next drift goes red on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s).

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

  • content/docs/releases/v15.mdx(via AutomationContext (symbol, a top-level interface))

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
  • 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 — 128 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 0d80862366f5181b5db75c5ba103c69db98df181packageMentionDocs.

Which tree this was computed on

This run read content/docs from c6078e63e62fafba017369a374d2836498d0139f — the merge of head aca37be257b3e88742e7b16ef84b61e779219f7c into base 0d80862366f5181b5db75c5ba103c69db98df181, 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 c6078e63e62fafba017369a374d2836498d0139f && git checkout c6078e63e62fafba017369a374d2836498d0139f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0d80862366f5181b5db75c5ba103c69db98df181 aca37be257b3e88742e7b16ef84b61e779219f7c && git checkout -B drift-repro 0d80862366f5181b5db75c5ba103c69db98df181 && git merge --no-ff aca37be257b3e88742e7b16ef84b61e779219f7c
node scripts/docs-audit/affected-docs.mjs --json 0d80862366f5181b5db75c5ba103c69db98df181

⚠️ 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 0d80862366f5181b5db75c5ba103c69db98df181 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Docs Drift Check 的两行都查过了,结论分开:一行是误报,一行是真的,已按守则立卡而非在此编辑。 本 PR 未触碰 content/docs/releases/,也不会去触碰。

它列的页结论
content/docs/releases/v15.mdx:863正确,无需动作。 原文是「Flow-type actions now receive the caller's identity as a real AutomationContextrunAs: 'user' flows evaluate RLS as the caller)」—— 讲的是 runAs: 'user',本身成立。它被列出只因为点名了 AutomationContext 这个符号,正是该 bot 宣称的 precision-first 行为在正常工作。
(bot 未列,我顺着同一个概念扫出来的)content/docs/releases/v17.mdx:2073真的有问题,已立 #14039

那条真的

v17.mdx:2072-2073#4366):

…and a runAs: 'system' flow's writes are audited as svc:flow:<flowName> instead of "Unknown user"

无限定,读起来就是所有 system 提权运行的通则 —— 也就是本 PR 正在契约散文里消灭的那个错概念。

同一个发布页v17.mdx:2860-2861#5494

runAs: 'system'create_record stamps all three ADR-0118 columns — organization, owner and creator are non-NULL.

两条各自都不假(一条讲 actor 标签、一条讲落库的三列),但并排读,靠前那条在靠后那条之前约 800 行,中间没有任何东西把它们连起来。front-to-back 读 v17 的人会先遇到无限定版本。

不建议直接改写 #4366 那条 —— 那会篡改它当时确实交付的东西。#14039 里给了两个保持记录诚实的选项(加限定+指引 / 依赖本 PR 修好的契约散文),并说明发布说明政策是发布流程的事,不是下游报告人的,所以我没有提 PR。

关于 dev 的扫描范围

派发词明令禁止编辑 content/docs/releases/,dev 遵守了。它的同族措辞扫描覆盖了 packages/spec/src、各包 JSDoc 与 content/docs/,报「exactly one site carried the wrong generalisation」。那个读数对本 PR 可改的范围是准的 —— v17 那条落在只读区,本来就不该出现在一个可改清单里。这不是漏扫,是边界。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给 spec 席位。 head aca37be 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:03、CI 01:10)。无冲突,无评审意见待处理。

保持 draft,我不转 ready 也不合 —— 这是本仓契约面的裁量权,不属于一个下游 PM 席位。我这边对本 PR 的驱动到此为止。

要落它需要知道的三件事,集中在这里免得翻楼:

  1. 运行时行为一个字没改,改的是描述它的散文加一份钉住不变式的测试。service-automation 93 文件 / 1099 测试全过,无既有断言移动。
  2. packages/spec patch 级 changeset —— 更正随发布的 .d.ts 出去。service-automation 不升版,它只多了一个测试文件。
  3. Docs Drift Check 的两行都查过了(见上方评论):v15.mdx 那行正确、无需动作;顺着同一概念另外扫到 v17.mdx:2073 确有问题,已按只读守则立卡不编辑v17 release notes: the #4366 entry states the svc:flow: audit label unqualified, and the #5494 entry later in the same page supersedes it — nothing connects them #14039)。本 PR 未触碰 content/docs/releases/

#14044#14010 半 B)不重叠:那张改 CLI/lint/runtime,本张只改 spec 契约散文与一个新测试。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 474242fSep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14011-runas-attribution-prose branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stale contract prose: AutomationContext.flowName says a runAs:'system' run "resolves no user", but #5494 made elevation carry the operator through

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution - #14035

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose
Sep 1, 2026
Merged

docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution#14035
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14011

A prose-only correction. The shipped runtime behaviour is right and is untouched — the sentence describing it was not.

The stale sentence

packages/spec/src/contracts/automation-service.ts, documenting AutomationContext.flowName (published as dist/contracts/index.d.ts, line 3033 on 17.1.0 / line 3000 on this branch's 17.2.0 build):

a runAs:'system' run resolves no user, so resolveRunDataContext labels its data operations svc:flow:FLOWNAME on ExecutionContext.actor … instead of leaving the audit row unattributed

That reads as "system elevation costs you the operator in the audit trail."

What actually ships — re-verified against origin/main, not taken from the card

The card's reading was measured on the 17.1.0 artifact; every claim below was re-derived from the source in this checkout at 62a137b.

  • packages/services/service-automation/src/runtime-identity.tsresolveRunDataContext, runAs:'system' branch: { isSystem: true, actor: 'svc:flow:' + flowName, ...(context.userId ? { userId: context.userId } : {}), ...(context.tenantId ? { tenantId } : {}), positions: [], permissions: [] }. The operator is carried through unchanged (Automation create_record under runAs:'system' inserts rows with owner_id/organization_id/created_by all NULL — records born untouchable even by admin #5494) — the comment on that line says so in as many words: "elevation is not anonymity … isSystem alone decides authorization, while the user drives the platform's attribution stamps."
  • packages/objectql/src/plugin.tssys_stamp_audit_insert / sys_stamp_audit_update stamp created_by / updated_by under if (session?.userId). There is no isSystem test anywhere on that path.
  • packages/plugins/plugin-audit/src/audit-writers.tswriteAudit computes userId = sess.userId ?? provenance.attributedUserId and actorLabel = userId ?? sess.actor. The svc:flow: label is the fallback, in that order, with no isSystem gate.

So the svc:flow: labelling the sentence described is real — but it is the user-less case (a schedule, or a runAs:'system' flow fired by a write that itself carried no user). The sentence generalised it to every runAs:'system' run.

What changed

1. The prose (packages/spec/src/contracts/automation-service.ts). The flowName JSDoc now states what is true of both cases: elevation decides AUTHORIZATION and leaves ATTRIBUTION alone; the svc:flow: actor label is the fallback a genuinely user-less run lands on, where ADR-0118 D1 keeps the user column null rather than inventing a sentinel.

2. A sweep for the same wrong concept. Searched packages/spec/src, every package's JSDoc, and content/docs/ for the family — resolves no user, unattributed, user-less, Unknown user, elevation/anonymity, and every prose mention of runAs: 'system' and svc:flow:. Exactly one site carried the wrong generalisation, the one fixed here. Everything else that mentions a user-less run is already scoped to the genuinely user-less case and was deliberately left alone (for instance content/docs/permissions/system-context.mdx line 200, "a user-less system write stamps nothing", and service-automation/src/engine.ts line 3436, "for runs that resolve no user" — both correct as written).

3. A line between the prose and the code that rings.packages/services/service-automation/src/runas-attribution-contract.test.ts asserts the invariant the prose now states, at the end of the chain — the session envelope the audit writers actually read — rather than at resolveRunDataContext's return shape, which builtin/crud-runas.test.ts already pins:

  • an elevated, user-triggered run updates a row created by a different user, and updated_by moves to the triggering operator — the same value the identical write produces on the plain user path, with created_by left on the creator;
  • a genuinely user-less run (schedule shape) creates a row whose user column stays NULL, and whose hook-visible session.actor is svc:flow:night_sweep.

If the code ever becomes what the old prose described, this file goes red instead of the next reader having to re-measure.

Reverse verification — and what it caught

Direction predicted before running: the operator limb goes RED, the user-less limb stays GREEN (it has no user to strip).

Ablation A — deleted the userId carry-through from resolveRunDataContext's system branch (i.e. made the code match the old prose). The first run reddened only the hook-session assertion; the updated_by column assertion passed under mutation, because the row had been seeded by the operator and already carried that value from its own insert. That assertion was measuring the insert, not the elevated update. Fixed by seeding the rows as a different user (usr_creator), so the column has to move for the assertion to pass. Re-ablated:

AssertionError: the elevated run must stamp the triggering operator: expected 'usr_creator' to be 'usr_operator'
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)

Ablation B — replaced the svc:flow: label with a constant, to check the second limb is live too:

AssertionError: the flow label names WHICH automation wrote (ADR-0014 D2): expected 'svc:ABLATED' to be 'svc:flow:elevated_touch'
AssertionError: the svc:flow: label is the fallback attribution: expected 'svc:ABLATED' to be 'svc:flow:night_sweep'
Test Files 1 failed (1)
Tests 2 failed (2)

Both ablations mutated a committed tree, proved the mutation on disk by grep counts plus a git hash-object comparison against the HEAD blob, carried an absolute-path trap … EXIT INT TERM, and restored to the byte-identical HEAD blob (git diff HEAD empty) before the next measurement. No rebuild step was needed for either: runtime-identity.ts is reached from builtin/crud-nodes.ts by a relative source import inside its own package, and this package's vitest config aliases only @objectstack/platform-objects — nothing routes its own modules through exports/dist.

Verification

All readings below are from aca37be, the branch head; the gate union was run after the final commit.

CheckResult
@objectstack/service-automation full suite93 files / 1099 tests passed — no existing behaviour assertion moved
@objectstack/spec full suite447 files / 11991 tests passed
@objectstack/spectsc --noEmit0 errors
@objectstack/service-automationtsc --noEmitexactly the 3 ledgered TS2341 in nested-region-parity.test.ts; 0 from the new file, and --listFiles confirms the new test file is in that program
pnpm lint (repo-wide eslint . --no-inline-config)exit 0, no findings
Derived gate family (scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands)48 commands, 45 green

The three non-green results are all exit 3 / PREREQUISITE NOT MET — nothing measured, not findings:

  • check:type-check-debt and check:dual-build-cjs-loads and scripts/check-dev-prereqs.mjs all refuse without a fully built workspace closure. CI builds it before those steps, exactly as lint.yml does. The substance the ratchet guards was measured directly instead, in the tsc --noEmit row above: the new test file is in the program and contributes zero errors, so the @objectstack/service-automation entry (errors: 3) does not move.
  • scripts/check-test-completeness.mjs (exit 3) needs a saved turbo run test log it cannot produce itself, and scripts/pm/check-half-states.mjs (exit 3) needs repo-scoped egress this container does not have. Both declare that exit as NOT MEASURED.

packages/spec was rebuilt after the edit, and the corrected sentence is present in dist/contracts/index.d.ts (1 occurrence) with the stale one gone (0 occurrences).

Notes for the reviewer


Generated by Claude Code

…14011)
The published contract said a `runAs:'system'` run "resolves no user", so the
`svc:flow:<flowName>` actor label stands in for the audit row's attribution.
That reads as "system elevation costs you the operator in the audit trail" and
has not been true since #5494: `resolveRunDataContext` carries the triggering
user through unchanged, `isSystem` alone decides authorization, and the audit
writers record `session.userId ?? session.actor` with no `isSystem` gate. The
`svc:flow:` label is the FALLBACK for a genuinely user-less run (a schedule).
Runtime behaviour is untouched. `runas-attribution-contract.test.ts` pins both
limbs against the real ObjectQL stack so the next drift goes red on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s).

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

  • content/docs/releases/v15.mdx(via AutomationContext (symbol, a top-level interface))

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
  • 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 — 128 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 0d80862366f5181b5db75c5ba103c69db98df181packageMentionDocs.

Which tree this was computed on

This run read content/docs from c6078e63e62fafba017369a374d2836498d0139f — the merge of head aca37be257b3e88742e7b16ef84b61e779219f7c into base 0d80862366f5181b5db75c5ba103c69db98df181, 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 c6078e63e62fafba017369a374d2836498d0139f && git checkout c6078e63e62fafba017369a374d2836498d0139f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0d80862366f5181b5db75c5ba103c69db98df181 aca37be257b3e88742e7b16ef84b61e779219f7c && git checkout -B drift-repro 0d80862366f5181b5db75c5ba103c69db98df181 && git merge --no-ff aca37be257b3e88742e7b16ef84b61e779219f7c
node scripts/docs-audit/affected-docs.mjs --json 0d80862366f5181b5db75c5ba103c69db98df181

⚠️ 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 0d80862366f5181b5db75c5ba103c69db98df181 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Docs Drift Check 的两行都查过了,结论分开:一行是误报,一行是真的,已按守则立卡而非在此编辑。 本 PR 未触碰 content/docs/releases/,也不会去触碰。

它列的页结论
content/docs/releases/v15.mdx:863正确,无需动作。 原文是「Flow-type actions now receive the caller's identity as a real AutomationContextrunAs: 'user' flows evaluate RLS as the caller)」—— 讲的是 runAs: 'user',本身成立。它被列出只因为点名了 AutomationContext 这个符号,正是该 bot 宣称的 precision-first 行为在正常工作。
(bot 未列,我顺着同一个概念扫出来的)content/docs/releases/v17.mdx:2073真的有问题,已立 #14039

那条真的

v17.mdx:2072-2073#4366):

…and a runAs: 'system' flow's writes are audited as svc:flow:<flowName> instead of "Unknown user"

无限定,读起来就是所有 system 提权运行的通则 —— 也就是本 PR 正在契约散文里消灭的那个错概念。

同一个发布页v17.mdx:2860-2861#5494

runAs: 'system'create_record stamps all three ADR-0118 columns — organization, owner and creator are non-NULL.

两条各自都不假(一条讲 actor 标签、一条讲落库的三列),但并排读,靠前那条在靠后那条之前约 800 行,中间没有任何东西把它们连起来。front-to-back 读 v17 的人会先遇到无限定版本。

不建议直接改写 #4366 那条 —— 那会篡改它当时确实交付的东西。#14039 里给了两个保持记录诚实的选项(加限定+指引 / 依赖本 PR 修好的契约散文),并说明发布说明政策是发布流程的事,不是下游报告人的,所以我没有提 PR。

关于 dev 的扫描范围

派发词明令禁止编辑 content/docs/releases/,dev 遵守了。它的同族措辞扫描覆盖了 packages/spec/src、各包 JSDoc 与 content/docs/,报「exactly one site carried the wrong generalisation」。那个读数对本 PR 可改的范围是准的 —— v17 那条落在只读区,本来就不该出现在一个可改清单里。这不是漏扫,是边界。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给 spec 席位。 head aca37be 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:03、CI 01:10)。无冲突,无评审意见待处理。

保持 draft,我不转 ready 也不合 —— 这是本仓契约面的裁量权,不属于一个下游 PM 席位。我这边对本 PR 的驱动到此为止。

要落它需要知道的三件事,集中在这里免得翻楼:

  1. 运行时行为一个字没改,改的是描述它的散文加一份钉住不变式的测试。service-automation 93 文件 / 1099 测试全过,无既有断言移动。
  2. packages/spec patch 级 changeset —— 更正随发布的 .d.ts 出去。service-automation 不升版,它只多了一个测试文件。
  3. Docs Drift Check 的两行都查过了(见上方评论):v15.mdx 那行正确、无需动作;顺着同一概念另外扫到 v17.mdx:2073 确有问题,已按只读守则立卡不编辑v17 release notes: the #4366 entry states the svc:flow: audit label unqualified, and the #5494 entry later in the same page supersedes it — nothing connects them #14039)。本 PR 未触碰 content/docs/releases/

#14044#14010 半 B)不重叠:那张改 CLI/lint/runtime,本张只改 spec 契约散文与一个新测试。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 474242fSep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14011-runas-attribution-prose branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stale contract prose: AutomationContext.flowName says a runAs:'system' run "resolves no user", but #5494 made elevation carry the operator through

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution - #14035

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose
Sep 1, 2026
Merged

docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution#14035
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14011

A prose-only correction. The shipped runtime behaviour is right and is untouched — the sentence describing it was not.

The stale sentence

packages/spec/src/contracts/automation-service.ts, documenting AutomationContext.flowName (published as dist/contracts/index.d.ts, line 3033 on 17.1.0 / line 3000 on this branch's 17.2.0 build):

a runAs:'system' run resolves no user, so resolveRunDataContext labels its data operations svc:flow:FLOWNAME on ExecutionContext.actor … instead of leaving the audit row unattributed

That reads as "system elevation costs you the operator in the audit trail."

What actually ships — re-verified against origin/main, not taken from the card

The card's reading was measured on the 17.1.0 artifact; every claim below was re-derived from the source in this checkout at 62a137b.

  • packages/services/service-automation/src/runtime-identity.tsresolveRunDataContext, runAs:'system' branch: { isSystem: true, actor: 'svc:flow:' + flowName, ...(context.userId ? { userId: context.userId } : {}), ...(context.tenantId ? { tenantId } : {}), positions: [], permissions: [] }. The operator is carried through unchanged (Automation create_record under runAs:'system' inserts rows with owner_id/organization_id/created_by all NULL — records born untouchable even by admin #5494) — the comment on that line says so in as many words: "elevation is not anonymity … isSystem alone decides authorization, while the user drives the platform's attribution stamps."
  • packages/objectql/src/plugin.tssys_stamp_audit_insert / sys_stamp_audit_update stamp created_by / updated_by under if (session?.userId). There is no isSystem test anywhere on that path.
  • packages/plugins/plugin-audit/src/audit-writers.tswriteAudit computes userId = sess.userId ?? provenance.attributedUserId and actorLabel = userId ?? sess.actor. The svc:flow: label is the fallback, in that order, with no isSystem gate.

So the svc:flow: labelling the sentence described is real — but it is the user-less case (a schedule, or a runAs:'system' flow fired by a write that itself carried no user). The sentence generalised it to every runAs:'system' run.

What changed

1. The prose (packages/spec/src/contracts/automation-service.ts). The flowName JSDoc now states what is true of both cases: elevation decides AUTHORIZATION and leaves ATTRIBUTION alone; the svc:flow: actor label is the fallback a genuinely user-less run lands on, where ADR-0118 D1 keeps the user column null rather than inventing a sentinel.

2. A sweep for the same wrong concept. Searched packages/spec/src, every package's JSDoc, and content/docs/ for the family — resolves no user, unattributed, user-less, Unknown user, elevation/anonymity, and every prose mention of runAs: 'system' and svc:flow:. Exactly one site carried the wrong generalisation, the one fixed here. Everything else that mentions a user-less run is already scoped to the genuinely user-less case and was deliberately left alone (for instance content/docs/permissions/system-context.mdx line 200, "a user-less system write stamps nothing", and service-automation/src/engine.ts line 3436, "for runs that resolve no user" — both correct as written).

3. A line between the prose and the code that rings.packages/services/service-automation/src/runas-attribution-contract.test.ts asserts the invariant the prose now states, at the end of the chain — the session envelope the audit writers actually read — rather than at resolveRunDataContext's return shape, which builtin/crud-runas.test.ts already pins:

  • an elevated, user-triggered run updates a row created by a different user, and updated_by moves to the triggering operator — the same value the identical write produces on the plain user path, with created_by left on the creator;
  • a genuinely user-less run (schedule shape) creates a row whose user column stays NULL, and whose hook-visible session.actor is svc:flow:night_sweep.

If the code ever becomes what the old prose described, this file goes red instead of the next reader having to re-measure.

Reverse verification — and what it caught

Direction predicted before running: the operator limb goes RED, the user-less limb stays GREEN (it has no user to strip).

Ablation A — deleted the userId carry-through from resolveRunDataContext's system branch (i.e. made the code match the old prose). The first run reddened only the hook-session assertion; the updated_by column assertion passed under mutation, because the row had been seeded by the operator and already carried that value from its own insert. That assertion was measuring the insert, not the elevated update. Fixed by seeding the rows as a different user (usr_creator), so the column has to move for the assertion to pass. Re-ablated:

AssertionError: the elevated run must stamp the triggering operator: expected 'usr_creator' to be 'usr_operator'
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)

Ablation B — replaced the svc:flow: label with a constant, to check the second limb is live too:

AssertionError: the flow label names WHICH automation wrote (ADR-0014 D2): expected 'svc:ABLATED' to be 'svc:flow:elevated_touch'
AssertionError: the svc:flow: label is the fallback attribution: expected 'svc:ABLATED' to be 'svc:flow:night_sweep'
Test Files 1 failed (1)
Tests 2 failed (2)

Both ablations mutated a committed tree, proved the mutation on disk by grep counts plus a git hash-object comparison against the HEAD blob, carried an absolute-path trap … EXIT INT TERM, and restored to the byte-identical HEAD blob (git diff HEAD empty) before the next measurement. No rebuild step was needed for either: runtime-identity.ts is reached from builtin/crud-nodes.ts by a relative source import inside its own package, and this package's vitest config aliases only @objectstack/platform-objects — nothing routes its own modules through exports/dist.

Verification

All readings below are from aca37be, the branch head; the gate union was run after the final commit.

CheckResult
@objectstack/service-automation full suite93 files / 1099 tests passed — no existing behaviour assertion moved
@objectstack/spec full suite447 files / 11991 tests passed
@objectstack/spectsc --noEmit0 errors
@objectstack/service-automationtsc --noEmitexactly the 3 ledgered TS2341 in nested-region-parity.test.ts; 0 from the new file, and --listFiles confirms the new test file is in that program
pnpm lint (repo-wide eslint . --no-inline-config)exit 0, no findings
Derived gate family (scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands)48 commands, 45 green

The three non-green results are all exit 3 / PREREQUISITE NOT MET — nothing measured, not findings:

  • check:type-check-debt and check:dual-build-cjs-loads and scripts/check-dev-prereqs.mjs all refuse without a fully built workspace closure. CI builds it before those steps, exactly as lint.yml does. The substance the ratchet guards was measured directly instead, in the tsc --noEmit row above: the new test file is in the program and contributes zero errors, so the @objectstack/service-automation entry (errors: 3) does not move.
  • scripts/check-test-completeness.mjs (exit 3) needs a saved turbo run test log it cannot produce itself, and scripts/pm/check-half-states.mjs (exit 3) needs repo-scoped egress this container does not have. Both declare that exit as NOT MEASURED.

packages/spec was rebuilt after the edit, and the corrected sentence is present in dist/contracts/index.d.ts (1 occurrence) with the stale one gone (0 occurrences).

Notes for the reviewer


Generated by Claude Code

…14011)
The published contract said a `runAs:'system'` run "resolves no user", so the
`svc:flow:<flowName>` actor label stands in for the audit row's attribution.
That reads as "system elevation costs you the operator in the audit trail" and
has not been true since #5494: `resolveRunDataContext` carries the triggering
user through unchanged, `isSystem` alone decides authorization, and the audit
writers record `session.userId ?? session.actor` with no `isSystem` gate. The
`svc:flow:` label is the FALLBACK for a genuinely user-less run (a schedule).
Runtime behaviour is untouched. `runas-attribution-contract.test.ts` pins both
limbs against the real ObjectQL stack so the next drift goes red on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s).

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

  • content/docs/releases/v15.mdx(via AutomationContext (symbol, a top-level interface))

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
  • 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 — 128 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 0d80862366f5181b5db75c5ba103c69db98df181packageMentionDocs.

Which tree this was computed on

This run read content/docs from c6078e63e62fafba017369a374d2836498d0139f — the merge of head aca37be257b3e88742e7b16ef84b61e779219f7c into base 0d80862366f5181b5db75c5ba103c69db98df181, 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 c6078e63e62fafba017369a374d2836498d0139f && git checkout c6078e63e62fafba017369a374d2836498d0139f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0d80862366f5181b5db75c5ba103c69db98df181 aca37be257b3e88742e7b16ef84b61e779219f7c && git checkout -B drift-repro 0d80862366f5181b5db75c5ba103c69db98df181 && git merge --no-ff aca37be257b3e88742e7b16ef84b61e779219f7c
node scripts/docs-audit/affected-docs.mjs --json 0d80862366f5181b5db75c5ba103c69db98df181

⚠️ 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 0d80862366f5181b5db75c5ba103c69db98df181 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Docs Drift Check 的两行都查过了,结论分开:一行是误报,一行是真的,已按守则立卡而非在此编辑。 本 PR 未触碰 content/docs/releases/,也不会去触碰。

它列的页结论
content/docs/releases/v15.mdx:863正确,无需动作。 原文是「Flow-type actions now receive the caller's identity as a real AutomationContextrunAs: 'user' flows evaluate RLS as the caller)」—— 讲的是 runAs: 'user',本身成立。它被列出只因为点名了 AutomationContext 这个符号,正是该 bot 宣称的 precision-first 行为在正常工作。
(bot 未列,我顺着同一个概念扫出来的)content/docs/releases/v17.mdx:2073真的有问题,已立 #14039

那条真的

v17.mdx:2072-2073#4366):

…and a runAs: 'system' flow's writes are audited as svc:flow:<flowName> instead of "Unknown user"

无限定,读起来就是所有 system 提权运行的通则 —— 也就是本 PR 正在契约散文里消灭的那个错概念。

同一个发布页v17.mdx:2860-2861#5494

runAs: 'system'create_record stamps all three ADR-0118 columns — organization, owner and creator are non-NULL.

两条各自都不假(一条讲 actor 标签、一条讲落库的三列),但并排读,靠前那条在靠后那条之前约 800 行,中间没有任何东西把它们连起来。front-to-back 读 v17 的人会先遇到无限定版本。

不建议直接改写 #4366 那条 —— 那会篡改它当时确实交付的东西。#14039 里给了两个保持记录诚实的选项(加限定+指引 / 依赖本 PR 修好的契约散文),并说明发布说明政策是发布流程的事,不是下游报告人的,所以我没有提 PR。

关于 dev 的扫描范围

派发词明令禁止编辑 content/docs/releases/,dev 遵守了。它的同族措辞扫描覆盖了 packages/spec/src、各包 JSDoc 与 content/docs/,报「exactly one site carried the wrong generalisation」。那个读数对本 PR 可改的范围是准的 —— v17 那条落在只读区,本来就不该出现在一个可改清单里。这不是漏扫,是边界。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给 spec 席位。 head aca37be 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:03、CI 01:10)。无冲突,无评审意见待处理。

保持 draft,我不转 ready 也不合 —— 这是本仓契约面的裁量权,不属于一个下游 PM 席位。我这边对本 PR 的驱动到此为止。

要落它需要知道的三件事,集中在这里免得翻楼:

  1. 运行时行为一个字没改,改的是描述它的散文加一份钉住不变式的测试。service-automation 93 文件 / 1099 测试全过,无既有断言移动。
  2. packages/spec patch 级 changeset —— 更正随发布的 .d.ts 出去。service-automation 不升版,它只多了一个测试文件。
  3. Docs Drift Check 的两行都查过了(见上方评论):v15.mdx 那行正确、无需动作;顺着同一概念另外扫到 v17.mdx:2073 确有问题,已按只读守则立卡不编辑v17 release notes: the #4366 entry states the svc:flow: audit label unqualified, and the #5494 entry later in the same page supersedes it — nothing connects them #14039)。本 PR 未触碰 content/docs/releases/

#14044#14010 半 B)不重叠:那张改 CLI/lint/runtime,本张只改 spec 契约散文与一个新测试。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 474242fSep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14011-runas-attribution-prose branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stale contract prose: AutomationContext.flowName says a runAs:'system' run "resolves no user", but #5494 made elevation carry the operator through

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution - #14035

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose
Sep 1, 2026
Merged

docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution#14035
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14011

A prose-only correction. The shipped runtime behaviour is right and is untouched — the sentence describing it was not.

The stale sentence

packages/spec/src/contracts/automation-service.ts, documenting AutomationContext.flowName (published as dist/contracts/index.d.ts, line 3033 on 17.1.0 / line 3000 on this branch's 17.2.0 build):

a runAs:'system' run resolves no user, so resolveRunDataContext labels its data operations svc:flow:FLOWNAME on ExecutionContext.actor … instead of leaving the audit row unattributed

That reads as "system elevation costs you the operator in the audit trail."

What actually ships — re-verified against origin/main, not taken from the card

The card's reading was measured on the 17.1.0 artifact; every claim below was re-derived from the source in this checkout at 62a137b.

  • packages/services/service-automation/src/runtime-identity.tsresolveRunDataContext, runAs:'system' branch: { isSystem: true, actor: 'svc:flow:' + flowName, ...(context.userId ? { userId: context.userId } : {}), ...(context.tenantId ? { tenantId } : {}), positions: [], permissions: [] }. The operator is carried through unchanged (Automation create_record under runAs:'system' inserts rows with owner_id/organization_id/created_by all NULL — records born untouchable even by admin #5494) — the comment on that line says so in as many words: "elevation is not anonymity … isSystem alone decides authorization, while the user drives the platform's attribution stamps."
  • packages/objectql/src/plugin.tssys_stamp_audit_insert / sys_stamp_audit_update stamp created_by / updated_by under if (session?.userId). There is no isSystem test anywhere on that path.
  • packages/plugins/plugin-audit/src/audit-writers.tswriteAudit computes userId = sess.userId ?? provenance.attributedUserId and actorLabel = userId ?? sess.actor. The svc:flow: label is the fallback, in that order, with no isSystem gate.

So the svc:flow: labelling the sentence described is real — but it is the user-less case (a schedule, or a runAs:'system' flow fired by a write that itself carried no user). The sentence generalised it to every runAs:'system' run.

What changed

1. The prose (packages/spec/src/contracts/automation-service.ts). The flowName JSDoc now states what is true of both cases: elevation decides AUTHORIZATION and leaves ATTRIBUTION alone; the svc:flow: actor label is the fallback a genuinely user-less run lands on, where ADR-0118 D1 keeps the user column null rather than inventing a sentinel.

2. A sweep for the same wrong concept. Searched packages/spec/src, every package's JSDoc, and content/docs/ for the family — resolves no user, unattributed, user-less, Unknown user, elevation/anonymity, and every prose mention of runAs: 'system' and svc:flow:. Exactly one site carried the wrong generalisation, the one fixed here. Everything else that mentions a user-less run is already scoped to the genuinely user-less case and was deliberately left alone (for instance content/docs/permissions/system-context.mdx line 200, "a user-less system write stamps nothing", and service-automation/src/engine.ts line 3436, "for runs that resolve no user" — both correct as written).

3. A line between the prose and the code that rings.packages/services/service-automation/src/runas-attribution-contract.test.ts asserts the invariant the prose now states, at the end of the chain — the session envelope the audit writers actually read — rather than at resolveRunDataContext's return shape, which builtin/crud-runas.test.ts already pins:

  • an elevated, user-triggered run updates a row created by a different user, and updated_by moves to the triggering operator — the same value the identical write produces on the plain user path, with created_by left on the creator;
  • a genuinely user-less run (schedule shape) creates a row whose user column stays NULL, and whose hook-visible session.actor is svc:flow:night_sweep.

If the code ever becomes what the old prose described, this file goes red instead of the next reader having to re-measure.

Reverse verification — and what it caught

Direction predicted before running: the operator limb goes RED, the user-less limb stays GREEN (it has no user to strip).

Ablation A — deleted the userId carry-through from resolveRunDataContext's system branch (i.e. made the code match the old prose). The first run reddened only the hook-session assertion; the updated_by column assertion passed under mutation, because the row had been seeded by the operator and already carried that value from its own insert. That assertion was measuring the insert, not the elevated update. Fixed by seeding the rows as a different user (usr_creator), so the column has to move for the assertion to pass. Re-ablated:

AssertionError: the elevated run must stamp the triggering operator: expected 'usr_creator' to be 'usr_operator'
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)

Ablation B — replaced the svc:flow: label with a constant, to check the second limb is live too:

AssertionError: the flow label names WHICH automation wrote (ADR-0014 D2): expected 'svc:ABLATED' to be 'svc:flow:elevated_touch'
AssertionError: the svc:flow: label is the fallback attribution: expected 'svc:ABLATED' to be 'svc:flow:night_sweep'
Test Files 1 failed (1)
Tests 2 failed (2)

Both ablations mutated a committed tree, proved the mutation on disk by grep counts plus a git hash-object comparison against the HEAD blob, carried an absolute-path trap … EXIT INT TERM, and restored to the byte-identical HEAD blob (git diff HEAD empty) before the next measurement. No rebuild step was needed for either: runtime-identity.ts is reached from builtin/crud-nodes.ts by a relative source import inside its own package, and this package's vitest config aliases only @objectstack/platform-objects — nothing routes its own modules through exports/dist.

Verification

All readings below are from aca37be, the branch head; the gate union was run after the final commit.

CheckResult
@objectstack/service-automation full suite93 files / 1099 tests passed — no existing behaviour assertion moved
@objectstack/spec full suite447 files / 11991 tests passed
@objectstack/spectsc --noEmit0 errors
@objectstack/service-automationtsc --noEmitexactly the 3 ledgered TS2341 in nested-region-parity.test.ts; 0 from the new file, and --listFiles confirms the new test file is in that program
pnpm lint (repo-wide eslint . --no-inline-config)exit 0, no findings
Derived gate family (scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands)48 commands, 45 green

The three non-green results are all exit 3 / PREREQUISITE NOT MET — nothing measured, not findings:

  • check:type-check-debt and check:dual-build-cjs-loads and scripts/check-dev-prereqs.mjs all refuse without a fully built workspace closure. CI builds it before those steps, exactly as lint.yml does. The substance the ratchet guards was measured directly instead, in the tsc --noEmit row above: the new test file is in the program and contributes zero errors, so the @objectstack/service-automation entry (errors: 3) does not move.
  • scripts/check-test-completeness.mjs (exit 3) needs a saved turbo run test log it cannot produce itself, and scripts/pm/check-half-states.mjs (exit 3) needs repo-scoped egress this container does not have. Both declare that exit as NOT MEASURED.

packages/spec was rebuilt after the edit, and the corrected sentence is present in dist/contracts/index.d.ts (1 occurrence) with the stale one gone (0 occurrences).

Notes for the reviewer


Generated by Claude Code

…14011)
The published contract said a `runAs:'system'` run "resolves no user", so the
`svc:flow:<flowName>` actor label stands in for the audit row's attribution.
That reads as "system elevation costs you the operator in the audit trail" and
has not been true since #5494: `resolveRunDataContext` carries the triggering
user through unchanged, `isSystem` alone decides authorization, and the audit
writers record `session.userId ?? session.actor` with no `isSystem` gate. The
`svc:flow:` label is the FALLBACK for a genuinely user-less run (a schedule).
Runtime behaviour is untouched. `runas-attribution-contract.test.ts` pins both
limbs against the real ObjectQL stack so the next drift goes red on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s).

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

  • content/docs/releases/v15.mdx(via AutomationContext (symbol, a top-level interface))

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
  • 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 — 128 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 0d80862366f5181b5db75c5ba103c69db98df181packageMentionDocs.

Which tree this was computed on

This run read content/docs from c6078e63e62fafba017369a374d2836498d0139f — the merge of head aca37be257b3e88742e7b16ef84b61e779219f7c into base 0d80862366f5181b5db75c5ba103c69db98df181, 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 c6078e63e62fafba017369a374d2836498d0139f && git checkout c6078e63e62fafba017369a374d2836498d0139f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0d80862366f5181b5db75c5ba103c69db98df181 aca37be257b3e88742e7b16ef84b61e779219f7c && git checkout -B drift-repro 0d80862366f5181b5db75c5ba103c69db98df181 && git merge --no-ff aca37be257b3e88742e7b16ef84b61e779219f7c
node scripts/docs-audit/affected-docs.mjs --json 0d80862366f5181b5db75c5ba103c69db98df181

⚠️ 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 0d80862366f5181b5db75c5ba103c69db98df181 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Docs Drift Check 的两行都查过了,结论分开:一行是误报,一行是真的,已按守则立卡而非在此编辑。 本 PR 未触碰 content/docs/releases/,也不会去触碰。

它列的页结论
content/docs/releases/v15.mdx:863正确,无需动作。 原文是「Flow-type actions now receive the caller's identity as a real AutomationContextrunAs: 'user' flows evaluate RLS as the caller)」—— 讲的是 runAs: 'user',本身成立。它被列出只因为点名了 AutomationContext 这个符号,正是该 bot 宣称的 precision-first 行为在正常工作。
(bot 未列,我顺着同一个概念扫出来的)content/docs/releases/v17.mdx:2073真的有问题,已立 #14039

那条真的

v17.mdx:2072-2073#4366):

…and a runAs: 'system' flow's writes are audited as svc:flow:<flowName> instead of "Unknown user"

无限定,读起来就是所有 system 提权运行的通则 —— 也就是本 PR 正在契约散文里消灭的那个错概念。

同一个发布页v17.mdx:2860-2861#5494

runAs: 'system'create_record stamps all three ADR-0118 columns — organization, owner and creator are non-NULL.

两条各自都不假(一条讲 actor 标签、一条讲落库的三列),但并排读,靠前那条在靠后那条之前约 800 行,中间没有任何东西把它们连起来。front-to-back 读 v17 的人会先遇到无限定版本。

不建议直接改写 #4366 那条 —— 那会篡改它当时确实交付的东西。#14039 里给了两个保持记录诚实的选项(加限定+指引 / 依赖本 PR 修好的契约散文),并说明发布说明政策是发布流程的事,不是下游报告人的,所以我没有提 PR。

关于 dev 的扫描范围

派发词明令禁止编辑 content/docs/releases/,dev 遵守了。它的同族措辞扫描覆盖了 packages/spec/src、各包 JSDoc 与 content/docs/,报「exactly one site carried the wrong generalisation」。那个读数对本 PR 可改的范围是准的 —— v17 那条落在只读区,本来就不该出现在一个可改清单里。这不是漏扫,是边界。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给 spec 席位。 head aca37be 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:03、CI 01:10)。无冲突,无评审意见待处理。

保持 draft,我不转 ready 也不合 —— 这是本仓契约面的裁量权,不属于一个下游 PM 席位。我这边对本 PR 的驱动到此为止。

要落它需要知道的三件事,集中在这里免得翻楼:

  1. 运行时行为一个字没改,改的是描述它的散文加一份钉住不变式的测试。service-automation 93 文件 / 1099 测试全过,无既有断言移动。
  2. packages/spec patch 级 changeset —— 更正随发布的 .d.ts 出去。service-automation 不升版,它只多了一个测试文件。
  3. Docs Drift Check 的两行都查过了(见上方评论):v15.mdx 那行正确、无需动作;顺着同一概念另外扫到 v17.mdx:2073 确有问题,已按只读守则立卡不编辑v17 release notes: the #4366 entry states the svc:flow: audit label unqualified, and the #5494 entry later in the same page supersedes it — nothing connects them #14039)。本 PR 未触碰 content/docs/releases/

#14044#14010 半 B)不重叠:那张改 CLI/lint/runtime,本张只改 spec 契约散文与一个新测试。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 474242fSep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14011-runas-attribution-prose branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stale contract prose: AutomationContext.flowName says a runAs:'system' run "resolves no user", but #5494 made elevation carry the operator through

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution - #14035

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose
Sep 1, 2026
Merged

docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution#14035
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14011

A prose-only correction. The shipped runtime behaviour is right and is untouched — the sentence describing it was not.

The stale sentence

packages/spec/src/contracts/automation-service.ts, documenting AutomationContext.flowName (published as dist/contracts/index.d.ts, line 3033 on 17.1.0 / line 3000 on this branch's 17.2.0 build):

a runAs:'system' run resolves no user, so resolveRunDataContext labels its data operations svc:flow:FLOWNAME on ExecutionContext.actor … instead of leaving the audit row unattributed

That reads as "system elevation costs you the operator in the audit trail."

What actually ships — re-verified against origin/main, not taken from the card

The card's reading was measured on the 17.1.0 artifact; every claim below was re-derived from the source in this checkout at 62a137b.

  • packages/services/service-automation/src/runtime-identity.tsresolveRunDataContext, runAs:'system' branch: { isSystem: true, actor: 'svc:flow:' + flowName, ...(context.userId ? { userId: context.userId } : {}), ...(context.tenantId ? { tenantId } : {}), positions: [], permissions: [] }. The operator is carried through unchanged (Automation create_record under runAs:'system' inserts rows with owner_id/organization_id/created_by all NULL — records born untouchable even by admin #5494) — the comment on that line says so in as many words: "elevation is not anonymity … isSystem alone decides authorization, while the user drives the platform's attribution stamps."
  • packages/objectql/src/plugin.tssys_stamp_audit_insert / sys_stamp_audit_update stamp created_by / updated_by under if (session?.userId). There is no isSystem test anywhere on that path.
  • packages/plugins/plugin-audit/src/audit-writers.tswriteAudit computes userId = sess.userId ?? provenance.attributedUserId and actorLabel = userId ?? sess.actor. The svc:flow: label is the fallback, in that order, with no isSystem gate.

So the svc:flow: labelling the sentence described is real — but it is the user-less case (a schedule, or a runAs:'system' flow fired by a write that itself carried no user). The sentence generalised it to every runAs:'system' run.

What changed

1. The prose (packages/spec/src/contracts/automation-service.ts). The flowName JSDoc now states what is true of both cases: elevation decides AUTHORIZATION and leaves ATTRIBUTION alone; the svc:flow: actor label is the fallback a genuinely user-less run lands on, where ADR-0118 D1 keeps the user column null rather than inventing a sentinel.

2. A sweep for the same wrong concept. Searched packages/spec/src, every package's JSDoc, and content/docs/ for the family — resolves no user, unattributed, user-less, Unknown user, elevation/anonymity, and every prose mention of runAs: 'system' and svc:flow:. Exactly one site carried the wrong generalisation, the one fixed here. Everything else that mentions a user-less run is already scoped to the genuinely user-less case and was deliberately left alone (for instance content/docs/permissions/system-context.mdx line 200, "a user-less system write stamps nothing", and service-automation/src/engine.ts line 3436, "for runs that resolve no user" — both correct as written).

3. A line between the prose and the code that rings.packages/services/service-automation/src/runas-attribution-contract.test.ts asserts the invariant the prose now states, at the end of the chain — the session envelope the audit writers actually read — rather than at resolveRunDataContext's return shape, which builtin/crud-runas.test.ts already pins:

  • an elevated, user-triggered run updates a row created by a different user, and updated_by moves to the triggering operator — the same value the identical write produces on the plain user path, with created_by left on the creator;
  • a genuinely user-less run (schedule shape) creates a row whose user column stays NULL, and whose hook-visible session.actor is svc:flow:night_sweep.

If the code ever becomes what the old prose described, this file goes red instead of the next reader having to re-measure.

Reverse verification — and what it caught

Direction predicted before running: the operator limb goes RED, the user-less limb stays GREEN (it has no user to strip).

Ablation A — deleted the userId carry-through from resolveRunDataContext's system branch (i.e. made the code match the old prose). The first run reddened only the hook-session assertion; the updated_by column assertion passed under mutation, because the row had been seeded by the operator and already carried that value from its own insert. That assertion was measuring the insert, not the elevated update. Fixed by seeding the rows as a different user (usr_creator), so the column has to move for the assertion to pass. Re-ablated:

AssertionError: the elevated run must stamp the triggering operator: expected 'usr_creator' to be 'usr_operator'
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)

Ablation B — replaced the svc:flow: label with a constant, to check the second limb is live too:

AssertionError: the flow label names WHICH automation wrote (ADR-0014 D2): expected 'svc:ABLATED' to be 'svc:flow:elevated_touch'
AssertionError: the svc:flow: label is the fallback attribution: expected 'svc:ABLATED' to be 'svc:flow:night_sweep'
Test Files 1 failed (1)
Tests 2 failed (2)

Both ablations mutated a committed tree, proved the mutation on disk by grep counts plus a git hash-object comparison against the HEAD blob, carried an absolute-path trap … EXIT INT TERM, and restored to the byte-identical HEAD blob (git diff HEAD empty) before the next measurement. No rebuild step was needed for either: runtime-identity.ts is reached from builtin/crud-nodes.ts by a relative source import inside its own package, and this package's vitest config aliases only @objectstack/platform-objects — nothing routes its own modules through exports/dist.

Verification

All readings below are from aca37be, the branch head; the gate union was run after the final commit.

CheckResult
@objectstack/service-automation full suite93 files / 1099 tests passed — no existing behaviour assertion moved
@objectstack/spec full suite447 files / 11991 tests passed
@objectstack/spectsc --noEmit0 errors
@objectstack/service-automationtsc --noEmitexactly the 3 ledgered TS2341 in nested-region-parity.test.ts; 0 from the new file, and --listFiles confirms the new test file is in that program
pnpm lint (repo-wide eslint . --no-inline-config)exit 0, no findings
Derived gate family (scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands)48 commands, 45 green

The three non-green results are all exit 3 / PREREQUISITE NOT MET — nothing measured, not findings:

  • check:type-check-debt and check:dual-build-cjs-loads and scripts/check-dev-prereqs.mjs all refuse without a fully built workspace closure. CI builds it before those steps, exactly as lint.yml does. The substance the ratchet guards was measured directly instead, in the tsc --noEmit row above: the new test file is in the program and contributes zero errors, so the @objectstack/service-automation entry (errors: 3) does not move.
  • scripts/check-test-completeness.mjs (exit 3) needs a saved turbo run test log it cannot produce itself, and scripts/pm/check-half-states.mjs (exit 3) needs repo-scoped egress this container does not have. Both declare that exit as NOT MEASURED.

packages/spec was rebuilt after the edit, and the corrected sentence is present in dist/contracts/index.d.ts (1 occurrence) with the stale one gone (0 occurrences).

Notes for the reviewer


Generated by Claude Code

…14011)
The published contract said a `runAs:'system'` run "resolves no user", so the
`svc:flow:<flowName>` actor label stands in for the audit row's attribution.
That reads as "system elevation costs you the operator in the audit trail" and
has not been true since #5494: `resolveRunDataContext` carries the triggering
user through unchanged, `isSystem` alone decides authorization, and the audit
writers record `session.userId ?? session.actor` with no `isSystem` gate. The
`svc:flow:` label is the FALLBACK for a genuinely user-less run (a schedule).
Runtime behaviour is untouched. `runas-attribution-contract.test.ts` pins both
limbs against the real ObjectQL stack so the next drift goes red on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s).

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

  • content/docs/releases/v15.mdx(via AutomationContext (symbol, a top-level interface))

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
  • 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 — 128 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 0d80862366f5181b5db75c5ba103c69db98df181packageMentionDocs.

Which tree this was computed on

This run read content/docs from c6078e63e62fafba017369a374d2836498d0139f — the merge of head aca37be257b3e88742e7b16ef84b61e779219f7c into base 0d80862366f5181b5db75c5ba103c69db98df181, 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 c6078e63e62fafba017369a374d2836498d0139f && git checkout c6078e63e62fafba017369a374d2836498d0139f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0d80862366f5181b5db75c5ba103c69db98df181 aca37be257b3e88742e7b16ef84b61e779219f7c && git checkout -B drift-repro 0d80862366f5181b5db75c5ba103c69db98df181 && git merge --no-ff aca37be257b3e88742e7b16ef84b61e779219f7c
node scripts/docs-audit/affected-docs.mjs --json 0d80862366f5181b5db75c5ba103c69db98df181

⚠️ 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 0d80862366f5181b5db75c5ba103c69db98df181 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Docs Drift Check 的两行都查过了,结论分开:一行是误报,一行是真的,已按守则立卡而非在此编辑。 本 PR 未触碰 content/docs/releases/,也不会去触碰。

它列的页结论
content/docs/releases/v15.mdx:863正确,无需动作。 原文是「Flow-type actions now receive the caller's identity as a real AutomationContextrunAs: 'user' flows evaluate RLS as the caller)」—— 讲的是 runAs: 'user',本身成立。它被列出只因为点名了 AutomationContext 这个符号,正是该 bot 宣称的 precision-first 行为在正常工作。
(bot 未列,我顺着同一个概念扫出来的)content/docs/releases/v17.mdx:2073真的有问题,已立 #14039

那条真的

v17.mdx:2072-2073#4366):

…and a runAs: 'system' flow's writes are audited as svc:flow:<flowName> instead of "Unknown user"

无限定,读起来就是所有 system 提权运行的通则 —— 也就是本 PR 正在契约散文里消灭的那个错概念。

同一个发布页v17.mdx:2860-2861#5494

runAs: 'system'create_record stamps all three ADR-0118 columns — organization, owner and creator are non-NULL.

两条各自都不假(一条讲 actor 标签、一条讲落库的三列),但并排读,靠前那条在靠后那条之前约 800 行,中间没有任何东西把它们连起来。front-to-back 读 v17 的人会先遇到无限定版本。

不建议直接改写 #4366 那条 —— 那会篡改它当时确实交付的东西。#14039 里给了两个保持记录诚实的选项(加限定+指引 / 依赖本 PR 修好的契约散文),并说明发布说明政策是发布流程的事,不是下游报告人的,所以我没有提 PR。

关于 dev 的扫描范围

派发词明令禁止编辑 content/docs/releases/,dev 遵守了。它的同族措辞扫描覆盖了 packages/spec/src、各包 JSDoc 与 content/docs/,报「exactly one site carried the wrong generalisation」。那个读数对本 PR 可改的范围是准的 —— v17 那条落在只读区,本来就不该出现在一个可改清单里。这不是漏扫,是边界。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给 spec 席位。 head aca37be 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:03、CI 01:10)。无冲突,无评审意见待处理。

保持 draft,我不转 ready 也不合 —— 这是本仓契约面的裁量权,不属于一个下游 PM 席位。我这边对本 PR 的驱动到此为止。

要落它需要知道的三件事,集中在这里免得翻楼:

  1. 运行时行为一个字没改,改的是描述它的散文加一份钉住不变式的测试。service-automation 93 文件 / 1099 测试全过,无既有断言移动。
  2. packages/spec patch 级 changeset —— 更正随发布的 .d.ts 出去。service-automation 不升版,它只多了一个测试文件。
  3. Docs Drift Check 的两行都查过了(见上方评论):v15.mdx 那行正确、无需动作;顺着同一概念另外扫到 v17.mdx:2073 确有问题,已按只读守则立卡不编辑v17 release notes: the #4366 entry states the svc:flow: audit label unqualified, and the #5494 entry later in the same page supersedes it — nothing connects them #14039)。本 PR 未触碰 content/docs/releases/

#14044#14010 半 B)不重叠:那张改 CLI/lint/runtime,本张只改 spec 契约散文与一个新测试。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 474242fSep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14011-runas-attribution-prose branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stale contract prose: AutomationContext.flowName says a runAs:'system' run "resolves no user", but #5494 made elevation carry the operator through

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution - #14035

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose
Sep 1, 2026
Merged

docs(spec): correct AutomationContext.flowName's attribution prose — elevation decides authorization, not attribution#14035
hotlong merged 1 commit into
mainfrom
claude/issue-14011-runas-attribution-prose

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14011

A prose-only correction. The shipped runtime behaviour is right and is untouched — the sentence describing it was not.

The stale sentence

packages/spec/src/contracts/automation-service.ts, documenting AutomationContext.flowName (published as dist/contracts/index.d.ts, line 3033 on 17.1.0 / line 3000 on this branch's 17.2.0 build):

a runAs:'system' run resolves no user, so resolveRunDataContext labels its data operations svc:flow:FLOWNAME on ExecutionContext.actor … instead of leaving the audit row unattributed

That reads as "system elevation costs you the operator in the audit trail."

What actually ships — re-verified against origin/main, not taken from the card

The card's reading was measured on the 17.1.0 artifact; every claim below was re-derived from the source in this checkout at 62a137b.

  • packages/services/service-automation/src/runtime-identity.tsresolveRunDataContext, runAs:'system' branch: { isSystem: true, actor: 'svc:flow:' + flowName, ...(context.userId ? { userId: context.userId } : {}), ...(context.tenantId ? { tenantId } : {}), positions: [], permissions: [] }. The operator is carried through unchanged (Automation create_record under runAs:'system' inserts rows with owner_id/organization_id/created_by all NULL — records born untouchable even by admin #5494) — the comment on that line says so in as many words: "elevation is not anonymity … isSystem alone decides authorization, while the user drives the platform's attribution stamps."
  • packages/objectql/src/plugin.tssys_stamp_audit_insert / sys_stamp_audit_update stamp created_by / updated_by under if (session?.userId). There is no isSystem test anywhere on that path.
  • packages/plugins/plugin-audit/src/audit-writers.tswriteAudit computes userId = sess.userId ?? provenance.attributedUserId and actorLabel = userId ?? sess.actor. The svc:flow: label is the fallback, in that order, with no isSystem gate.

So the svc:flow: labelling the sentence described is real — but it is the user-less case (a schedule, or a runAs:'system' flow fired by a write that itself carried no user). The sentence generalised it to every runAs:'system' run.

What changed

1. The prose (packages/spec/src/contracts/automation-service.ts). The flowName JSDoc now states what is true of both cases: elevation decides AUTHORIZATION and leaves ATTRIBUTION alone; the svc:flow: actor label is the fallback a genuinely user-less run lands on, where ADR-0118 D1 keeps the user column null rather than inventing a sentinel.

2. A sweep for the same wrong concept. Searched packages/spec/src, every package's JSDoc, and content/docs/ for the family — resolves no user, unattributed, user-less, Unknown user, elevation/anonymity, and every prose mention of runAs: 'system' and svc:flow:. Exactly one site carried the wrong generalisation, the one fixed here. Everything else that mentions a user-less run is already scoped to the genuinely user-less case and was deliberately left alone (for instance content/docs/permissions/system-context.mdx line 200, "a user-less system write stamps nothing", and service-automation/src/engine.ts line 3436, "for runs that resolve no user" — both correct as written).

3. A line between the prose and the code that rings.packages/services/service-automation/src/runas-attribution-contract.test.ts asserts the invariant the prose now states, at the end of the chain — the session envelope the audit writers actually read — rather than at resolveRunDataContext's return shape, which builtin/crud-runas.test.ts already pins:

  • an elevated, user-triggered run updates a row created by a different user, and updated_by moves to the triggering operator — the same value the identical write produces on the plain user path, with created_by left on the creator;
  • a genuinely user-less run (schedule shape) creates a row whose user column stays NULL, and whose hook-visible session.actor is svc:flow:night_sweep.

If the code ever becomes what the old prose described, this file goes red instead of the next reader having to re-measure.

Reverse verification — and what it caught

Direction predicted before running: the operator limb goes RED, the user-less limb stays GREEN (it has no user to strip).

Ablation A — deleted the userId carry-through from resolveRunDataContext's system branch (i.e. made the code match the old prose). The first run reddened only the hook-session assertion; the updated_by column assertion passed under mutation, because the row had been seeded by the operator and already carried that value from its own insert. That assertion was measuring the insert, not the elevated update. Fixed by seeding the rows as a different user (usr_creator), so the column has to move for the assertion to pass. Re-ablated:

AssertionError: the elevated run must stamp the triggering operator: expected 'usr_creator' to be 'usr_operator'
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)

Ablation B — replaced the svc:flow: label with a constant, to check the second limb is live too:

AssertionError: the flow label names WHICH automation wrote (ADR-0014 D2): expected 'svc:ABLATED' to be 'svc:flow:elevated_touch'
AssertionError: the svc:flow: label is the fallback attribution: expected 'svc:ABLATED' to be 'svc:flow:night_sweep'
Test Files 1 failed (1)
Tests 2 failed (2)

Both ablations mutated a committed tree, proved the mutation on disk by grep counts plus a git hash-object comparison against the HEAD blob, carried an absolute-path trap … EXIT INT TERM, and restored to the byte-identical HEAD blob (git diff HEAD empty) before the next measurement. No rebuild step was needed for either: runtime-identity.ts is reached from builtin/crud-nodes.ts by a relative source import inside its own package, and this package's vitest config aliases only @objectstack/platform-objects — nothing routes its own modules through exports/dist.

Verification

All readings below are from aca37be, the branch head; the gate union was run after the final commit.

CheckResult
@objectstack/service-automation full suite93 files / 1099 tests passed — no existing behaviour assertion moved
@objectstack/spec full suite447 files / 11991 tests passed
@objectstack/spectsc --noEmit0 errors
@objectstack/service-automationtsc --noEmitexactly the 3 ledgered TS2341 in nested-region-parity.test.ts; 0 from the new file, and --listFiles confirms the new test file is in that program
pnpm lint (repo-wide eslint . --no-inline-config)exit 0, no findings
Derived gate family (scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands)48 commands, 45 green

The three non-green results are all exit 3 / PREREQUISITE NOT MET — nothing measured, not findings:

  • check:type-check-debt and check:dual-build-cjs-loads and scripts/check-dev-prereqs.mjs all refuse without a fully built workspace closure. CI builds it before those steps, exactly as lint.yml does. The substance the ratchet guards was measured directly instead, in the tsc --noEmit row above: the new test file is in the program and contributes zero errors, so the @objectstack/service-automation entry (errors: 3) does not move.
  • scripts/check-test-completeness.mjs (exit 3) needs a saved turbo run test log it cannot produce itself, and scripts/pm/check-half-states.mjs (exit 3) needs repo-scoped egress this container does not have. Both declare that exit as NOT MEASURED.

packages/spec was rebuilt after the edit, and the corrected sentence is present in dist/contracts/index.d.ts (1 occurrence) with the stale one gone (0 occurrences).

Notes for the reviewer


Generated by Claude Code

…14011)
The published contract said a `runAs:'system'` run "resolves no user", so the
`svc:flow:<flowName>` actor label stands in for the audit row's attribution.
That reads as "system elevation costs you the operator in the audit trail" and
has not been true since #5494: `resolveRunDataContext` carries the triggering
user through unchanged, `isSystem` alone decides authorization, and the audit
writers record `session.userId ?? session.actor` with no `isSystem` gate. The
`svc:flow:` label is the FALLBACK for a genuinely user-less run (a schedule).
Runtime behaviour is untouched. `runas-attribution-contract.test.ts` pins both
limbs against the real ObjectQL stack so the next drift goes red on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s).

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

  • content/docs/releases/v15.mdx(via AutomationContext (symbol, a top-level interface))

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
  • 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 — 128 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 0d80862366f5181b5db75c5ba103c69db98df181packageMentionDocs.

Which tree this was computed on

This run read content/docs from c6078e63e62fafba017369a374d2836498d0139f — the merge of head aca37be257b3e88742e7b16ef84b61e779219f7c into base 0d80862366f5181b5db75c5ba103c69db98df181, 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 c6078e63e62fafba017369a374d2836498d0139f && git checkout c6078e63e62fafba017369a374d2836498d0139f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0d80862366f5181b5db75c5ba103c69db98df181 aca37be257b3e88742e7b16ef84b61e779219f7c && git checkout -B drift-repro 0d80862366f5181b5db75c5ba103c69db98df181 && git merge --no-ff aca37be257b3e88742e7b16ef84b61e779219f7c
node scripts/docs-audit/affected-docs.mjs --json 0d80862366f5181b5db75c5ba103c69db98df181

⚠️ 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 0d80862366f5181b5db75c5ba103c69db98df181 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Docs Drift Check 的两行都查过了,结论分开:一行是误报,一行是真的,已按守则立卡而非在此编辑。 本 PR 未触碰 content/docs/releases/,也不会去触碰。

它列的页结论
content/docs/releases/v15.mdx:863正确,无需动作。 原文是「Flow-type actions now receive the caller's identity as a real AutomationContextrunAs: 'user' flows evaluate RLS as the caller)」—— 讲的是 runAs: 'user',本身成立。它被列出只因为点名了 AutomationContext 这个符号,正是该 bot 宣称的 precision-first 行为在正常工作。
(bot 未列,我顺着同一个概念扫出来的)content/docs/releases/v17.mdx:2073真的有问题,已立 #14039

那条真的

v17.mdx:2072-2073#4366):

…and a runAs: 'system' flow's writes are audited as svc:flow:<flowName> instead of "Unknown user"

无限定,读起来就是所有 system 提权运行的通则 —— 也就是本 PR 正在契约散文里消灭的那个错概念。

同一个发布页v17.mdx:2860-2861#5494

runAs: 'system'create_record stamps all three ADR-0118 columns — organization, owner and creator are non-NULL.

两条各自都不假(一条讲 actor 标签、一条讲落库的三列),但并排读,靠前那条在靠后那条之前约 800 行,中间没有任何东西把它们连起来。front-to-back 读 v17 的人会先遇到无限定版本。

不建议直接改写 #4366 那条 —— 那会篡改它当时确实交付的东西。#14039 里给了两个保持记录诚实的选项(加限定+指引 / 依赖本 PR 修好的契约散文),并说明发布说明政策是发布流程的事,不是下游报告人的,所以我没有提 PR。

关于 dev 的扫描范围

派发词明令禁止编辑 content/docs/releases/,dev 遵守了。它的同族措辞扫描覆盖了 packages/spec/src、各包 JSDoc 与 content/docs/,报「exactly one site carried the wrong generalisation」。那个读数对本 PR 可改的范围是准的 —— v17 那条落在只读区,本来就不该出现在一个可改清单里。这不是漏扫,是边界。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给 spec 席位。 head aca37be 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:03、CI 01:10)。无冲突,无评审意见待处理。

保持 draft,我不转 ready 也不合 —— 这是本仓契约面的裁量权,不属于一个下游 PM 席位。我这边对本 PR 的驱动到此为止。

要落它需要知道的三件事,集中在这里免得翻楼:

  1. 运行时行为一个字没改,改的是描述它的散文加一份钉住不变式的测试。service-automation 93 文件 / 1099 测试全过,无既有断言移动。
  2. packages/spec patch 级 changeset —— 更正随发布的 .d.ts 出去。service-automation 不升版,它只多了一个测试文件。
  3. Docs Drift Check 的两行都查过了(见上方评论):v15.mdx 那行正确、无需动作;顺着同一概念另外扫到 v17.mdx:2073 确有问题,已按只读守则立卡不编辑v17 release notes: the #4366 entry states the svc:flow: audit label unqualified, and the #5494 entry later in the same page supersedes it — nothing connects them #14039)。本 PR 未触碰 content/docs/releases/

#14044#14010 半 B)不重叠:那张改 CLI/lint/runtime,本张只改 spec 契约散文与一个新测试。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 474242fSep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14011-runas-attribution-prose branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stale contract prose: AutomationContext.flowName says a runAs:'system' run "resolves no user", but #5494 made elevation carry the operator through

2 participants

@hotlong@claude