Skip to content

fix(objectql)!: a runAs system flow's record writes keep the reference check while keeping their authorization elevation (#28881) - #29060

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-i6gtvk
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-i6gtvk

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #28881

Requested by Leehom · project thread

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under skills/** says a system flow's writes skip the reference check; skills/objectstack-data/rules/security.md names reference_not_found only for the grant catalog, which this does not touch. The one docs page that states the exemption, content/docs/permissions/system-context.mdx row 23 (and row 29c's aside), is updated in this PR.

维护者速读

  • 改了什么:runAs: 'system' 的流程写记录时(create_record / update_record,以及任何经 resolveRunDataContext 写数据的节点)不再跳过引用校验。写入一个已删除或不存在的 sys_user(或任何 lookup / master-detail 目标)时,按用户路径原样拒绝:VALIDATION_FAILED,字段错误 reference_not_found,什么都不写。
  • 怎么做的:@objectstack/core 新增一个内部标记(Symbol.for,不在任何 schema 里,作者写不出来,ExecutionContextSchema.parse 会丢掉它),只在 resolveRunDataContext 把流程提升为 system 的那一处打上。assertReferencesResolve 的 system 早退只对带标记的上下文不生效。走的是 APP_AUTOMATION_ELEVATION 的同一套先例。
  • 不变的:流程的授权提升完全不变(照样能写别人的行、绕过 RLS、限定在触发者组织内)。其他所有 system 写入者都保留豁免:种子、导入、恢复/迁移、级联与置空、启动写入、hook 与 action 自己的写。没有新错误码,没有新 key,没有新门禁,不碰 packages/spec/**。
  • 风险与回滚:只会拒绝"流程写了一个不存在的引用"这一种写入。先测量过:去掉豁免后,红的全是种子/单测夹具和 verify 启动探针,没有一个流程在合法地写尚不存在的引用;收窄后 dogfood 全绿。回滚 = revert 本 PR。
  • 先看:packages/core/src/security/flow-write-reference-check.ts;packages/objectql/src/engine-referential-integrity.ts 的早退那一行;packages/services/service-automation/src/runtime-identity.ts 的 system 分支。
  • 两分钟验证:pnpm --filter @objectstack/service-automation exec vitest run src/runas-system-reference-check.integration.test.ts,预期 4 个测试全绿。
  • 需要你做的:无,席位安排评审并落地。

What changes

  • The mark (packages/core/src/security/flow-write-reference-check.ts, new; exported from @objectstack/core). FLOW_WRITE_REFERENCE_CHECK = Symbol.for('objectstack.core.flowWriteReferenceCheck'), markFlowWriteReferenceCheck(context) and keepsFlowWriteReferenceCheck(context). It follows APP_AUTOMATION_ELEVATION: a registry symbol survives the { ...context } spreads between the node and the engine, no JSON body or flow definition can carry one, and the schema parse drops it. It lives in core because service-automation depends on core at runtime and has objectql only as a dev dependency.
  • Where it is set (runtime-identity.ts). Only in the runAs: 'system' arm of resolveRunDataContext, which every CRUD node and custom node reaches through. The user arm never carries it (crud-runas.test.ts pins both arms with toEqual, which compares symbol keys).
  • Where it is read (engine-referential-integrity.ts). if (context?.isSystem) return; becomes if (context?.isSystem && !keepsFlowWriteReferenceCheck(context)) return;. Everything after it is the user path's check unchanged: only caller-supplied values, probed under the existing referenceCheckContext (tenant kept), and the master-detail header read, which for a system caller returns every header in its organization.
  • The owner stamp. stampSystemInsertOwner fills owner_id from the trigger user into the written fields, so it is now checked too. A system flow whose trigger user has no sys_user row is refused on insert. That is the case the card describes (an owner that resolves to no account), and the changeset names it.
  • Docblocks on ObjectQL#assertReferencesResolve and masterDetailHeaderExists updated; system-context.mdx row 23 names the one system caller that keeps the check.
  • .changeset/28881-flow-write-reference-check.md: patch on core, objectql and service-automation, both declaration lines, the ADR-0087 not-required (no-migration-prescription) marker and the BREAKING banner.

Measurement first (ruling 2)

The exemption was removed by an always-false guard carrying the marker MEASURE_NO_EXEMPT, objectql rebuilt and the marker counted in dist, then restored and rebuilt.

Suite with no exemption Reds Who
@objectstack/objectql 5 files / 7 tests Unit fixtures writing a dangling or forward reference under system on purpose: engine-dangling-reference-audit, engine-lookup-referential-integrity, engine-reference-tenant-scope, engine-multi-reference-element-shape, engine-formula-hop-stored-metadata-body.
@objectstack/service-automation 1 file / 1 test runas-system-stamping: the flow's owner stamp names trigger user usr_member, which the fixture never inserted as a sys_user. A fixture gap, fixed in this PR by seeding the row.
@objectstack/runtime 5 files Seed writes and stored-metadata pins: seed-blank-reference-real-driver, the external-object spike, two stored-metadata pins, the unresolved-object-name pin.
dogfood 170 files / 41 tests Nearly all are one cause: the verify harness's boot probe inserts sys_invitation naming org_verify_audience_gate / usr_verify_audience_gate (packages/verify/src/harness.ts) as a system write, so most files never boot. The rest are showcase system inserts in tests.

System callers that reach the check, by grep: plugin-auth (23 files), plugin-audit, metadata-protocol (seed loader, restore), storage, sharing, security, runtime (AppPlugin seeds), objectql (cascade, set-null), approvals, settings, verify and service-automation. None of the reds is a flow writing a reference that does not exist yet, so the stop condition did not trigger. The direct check is the dogfood on the narrowed head, where every flow run keeps the check: 382 files green.

Not measured separately: the qa seeds. Seeds do not run through resolveRunDataContext, so they keep the exemption, and the seed control below pins that.

Tests

packages/services/service-automation/src/runas-system-reference-check.integration.test.ts (new, 4 tests). The rig is real: ObjectKernel, ObjectQLPlugin, driver-sql on better-sqlite3, the real AutomationEngine and CRUD nodes, plugin-auth's identity objects and the real SecurityPlugin. crm_task.assignee is a lookup to sys_user, and usr_deleted is inserted and then deleted.

  • create_record naming the deleted user: the run fails, innerRefusal equals { code: 'VALIDATION_FAILED', message } where message is the user path's own sentence for the same write (Assignee: no sys_user record has id "usr_deleted", field code reference_not_found), and no row exists.
  • update_record: refused the same way, and the row keeps its assignee.
  • Elevation control: the trigger user's own update of another member's row is PERMISSION_DENIED; the same system flow's update of it succeeds.
  • Control: a seed write (SEED_WRITE_EXECUTION_CONTEXT) and an import's batched create (insertMany under { isSystem, skipAutomations, skipStateMachine }, the context the import door builds) both still store usr_not_yet_created.
Reading Result
Base (production files restored to origin/main, tests kept, core and objectql rebuilt with pnpm --filter) Tests 2 failed, 8 passed (10) over the new file and runas-system-stamping: red are create and update; both controls and stamping green.
Head Tests 10 passed (10)
Ablation

From committed HEAD, && !keepsFlowWriteReferenceCheck(context)) return; replaced by a guarded form (marker ABL_M, counted in dist), objectql rebuilt: Tests 2 failed, 8 passed (10), exactly create and update. Restored: blob equals HEAD, git diff HEAD empty, rebuilt dist has 0 markers.

Suites, typecheck, lint, dogfood and gates
Run Result
pnpm build exit 0
@objectstack/objectql 525 files · 10040 tests
@objectstack/core 135 files · 2986 tests
@objectstack/runtime 516 files · 9270 passed, 19 skipped
@objectstack/service-automation 256 files · 3285 tests (after the pin update)
typecheck core, objectql, service-automation exit 0 each
eslint over the touched packages' changed files exit 0
Full dogfood (narrowing arm), head 09dbbaeb Test Files 382 passed, 3 skipped (385) · Tests 3156 passed, 28 skipped (3184). No reds, so none to attribute to #28849 on this main. The two later commits change test pins and docs only.
dispatch-gates.mjs --commands (98 families) plus check:type-check-debt all 98 derived families exit 0 at head 55624b17 (67 on the code diff, 31 more once the docs page joined it), plus check:type-check-debt exit 0; --ran: 98 run, 0 NOT-MEASURED. check:system-context-census exit 0 after the row edit. No gate added.

Out of scope

A multi-user (multiple: true) reference field keeps a deleted user's id after the user is deleted. That is not a write-time check and ruling 4 leaves it out; it is in the report with the seat as carrier.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5kkSYvKhs52WGfTj3c

…e check

The write-time reference check returned early for every system context,
so a runAs: 'system' flow's create_record / update_record stored a
reference to a deleted row with no error, while the same write on the
user path was refused reference_not_found.

resolveRunDataContext now marks a system run's data context with an
internal symbol (markFlowWriteReferenceCheck, @objectstack/core), and
assertReferencesResolve keeps checking a marked write. The run keeps
its authorization elevation; every other system writer keeps the
exemption.

Co-Authored-By: Claude <noreply@anthropic.com>
…the system arm

resolveRunDataContext's system arm now carries the internal
FLOW_WRITE_REFERENCE_CHECK mark; toEqual compares symbol keys, so the
system-arm pins name it and the user-arm pin keeps saying a user run never
carries it.

Co-Authored-By: Claude <noreply@anthropic.com>
…ence check

Row 23 of the system-context census now names the one system caller that
keeps the referential-integrity check, a flow run's elevated context, and
row 29c's parenthetical no longer says every system write skips it.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/core, @objectstack/objectql, @objectstack/service-automation, touching 6 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/core/src/security/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/system-context.mdx (via assertReferencesResolve (symbol, a top-level function), resolveRunDataContext (symbol, a top-level function))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/core/src/security/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 79 pages)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 51 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 d1f3ac05038cffc28b9f8bcbe2116afe1b5f1a2e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6a818b16de88ec72fa729bbc938123fb1b9ba84b — the merge of head 085ef921fcbfde3087b9470acf5d0aa08d4e76bd into base d1f3ac05038cffc28b9f8bcbe2116afe1b5f1a2e, 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 6a818b16de88ec72fa729bbc938123fb1b9ba84b && git checkout 6a818b16de88ec72fa729bbc938123fb1b9ba84b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d1f3ac05038cffc28b9f8bcbe2116afe1b5f1a2e 085ef921fcbfde3087b9470acf5d0aa08d4e76bd && git checkout -B drift-repro d1f3ac05038cffc28b9f8bcbe2116afe1b5f1a2e && git merge --no-ff 085ef921fcbfde3087b9470acf5d0aa08d4e76bd

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Contract-review-claim: session_01R1q2xdtYgU6FKrZ7LgjPCC · head 55624b17ae88981879baf70dd34cc65bc97fb7a5 · 2026-11-02T06:39Z

Taken by the dispatching seat. Face narrowing (Clause-②: no (narrowing), Metadata-protocol: none), at CONTRACT_REVIEW_TIER with ④. The review runs as an isolated sub-agent.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 55624b17ae88981879baf70dd34cc65bc97fb7a5
Local-runs: none
Scope: packages/core/src/security/flow-write-reference-check.ts, packages/core/src/security/index.ts, packages/objectql/src/engine-referential-integrity.ts, packages/objectql/src/engine.ts, packages/services/service-automation/src/runtime-identity.ts, packages/services/service-automation/src/builtin/crud-runas.test.ts, packages/services/service-automation/src/runas-system-reference-check.integration.test.ts, packages/services/service-automation/src/runas-system-stamping.integration.test.ts, packages/services/service-automation/src/runas-user-posture-rung.integration.test.ts + packages/services/service-automation/src/builtin/crud-nodes.ts, packages/core/src/security/app-automation-elevation.ts, packages/runtime/src/endpoint-executor.ts, packages/runtime/src/domains/automation.ts, packages/plugins/plugin-auth/src/principal-session-mint.ts, packages/platform-objects/src/identity/sys-user.object.ts, examples/app-showcase/src/automation/flows/index.ts, examples/app-todo/src/flows/task.flow.ts, hotcrm src/revenue/flows/, src/sales/flows/, src/revenue/objects/.hook.ts, src/sales/objects/.hook.ts (read on hotcrm origin/main d4ffe990; the checkout carries no diff on those paths). The two prose faces (.changeset/28881-flow-write-reference-check.md, content/docs/permissions/system-context.mdx) are read for ② and ④, not reviewed.

Record rendered 2026-11-02T06:47Z from card #28881 (body + 3 comments: triage#1, the seat's Claim, dev report 6570817610), PR #29060 (body, 11-file list, net diff origin/main...55624b17, merge base b15b634), the head's 42 check-runs, and the cited code read with git show at the head. No worktree, no build, no test, no gate re-run.

① Derived judgments

Accept-set changes the diff implies, each judged:

  1. A runAs: 'system' flow's create_record / update_record (single and multi: true), and any custom node that builds its context through resolveRunDataContext, naming a lookup / user / master_detail id that resolves to no row in the run's organization: admitted → refused VALIDATION_FAILED with the field's reference_not_found, carried as the node's innerRefusal. Right. The card's ask, ruling 3's route. The mark is set at the one system arm of resolveRunDataContext (runtime-identity.ts L342) and read at the one early exit (engine-referential-integrity.ts L94); every CRUD node reaches the engine through resolveRunDataContext (crud-nodes.ts L533/630/784/882) and opCtx.context is options.context untouched (engine.ts L15383, L16837, L20502), so the symbol key reaches all three assertReferencesResolve call sites (engine.ts L16342 insert, L18154 by-id update, L18580 predicate update). Everything after the exit is the user path's check byte for byte; the probe keeps running under referenceCheckContext ({ ...context, isSystem: true }, tenant kept). Same words, same code as the user path: no new error code.

  2. The owner stamp is checked too. stampSystemInsertOwner (runtime-identity.ts L453) writes owner_id = trigger user INTO fields before data.insert, so it is caller-supplied in assertReferencesResolve's sense (on the user path the middleware stamps it after the check, so it is never checked there). A system flow whose trigger user has no sys_user row is refused on insert with Owner: no sys_user record has id …. Right, and named in the PR body and changeset: it is exactly the card's measured case (an owner that resolves to no account). Trigger-kind walk, below in ③.

  3. Nested writes under a flow-run write are checked as the flow's — and the PR says the opposite. The mark rides every spread of the context, as flowRunId does. Three spreads the diff does not name:

    • a hook fired by the flow's write gets buildHookApi(opCtx.context) (engine.ts L8261, L14533/14843/15666/17267/20532): new ScopedContext(execCtx, …), the RAW context, not a parse. createContext (L22227) is the only parse, and hook dispatch does not go through it. So a hook running inherit under the flow's system context, or runAs: 'system' (withRunAs, L22600 spreads { ...this.executionContext, isSystem: true }), or sudo() (L22540), writes with the mark and is reference-checked;
    • the cascade legs of a flow-run delete_record: referentialCtx = { ...context, __referentialFieldClear… } (engine.ts L20063). The set_null leg writes null (not a reference, unchecked); the multi-value member-removal leg (L20093) rewrites the REMAINING ids, which are then checked — a remaining dangling id (pre-existing dirt; the card's own out-of-scope finding) refuses the flow's delete;
    • an action node is not a route (service-automation has no action dispatch; dispatchFlowAction is runtime → flow), so action bodies keep their own markAppAutomationElevation({ ...base, isSystem: true }) envelope untouched.
      The behaviour is the APP_AUTOMATION_ELEVATION precedent's own rule ("a write the hook's or the body's own write sets off under the same context … is judged as theirs") and on the user path these same nested writes are already checked, so the design is consistent. But the changeset's "Unchanged" bullet ("a hook's or action body's own writes" keep the exemption, "cascade and set-null legs" too), the core module header ("a write made through a parsed context (a hook's ctx.api) is judged as that context's own write") and the PR body's 不变的 line state the opposite, on a wrong premise (hook ctx.api is not parsed). Wrong as a record; should-fix in ④.
  4. Public surface. @objectstack/core gains three exports (FLOW_WRITE_REFERENCE_CHECK, markFlowWriteReferenceCheck, keepsFlowWriteReferenceCheck), additive, the exact shape of APP_AUTOMATION_ELEVATION; resolveRunDataContext's system arm now returns an object with one own symbol key (the toEqual pins name it; a user arm never carries it, pinned by the unchanged user-arm toEqual). No packages/spec/**, no schema, no authorable key, no error code. Right.

  5. masterDetailHeaderExists is newly reachable by a system caller (a flow run's write only). masterDetailHeadersCallerReads returns every asked id for isSystem (engine-referential-integrity.ts L172), then referenceExists under the tenant: a master-detail header is judged on existence in the caller's organization, as the updated docblock says. Right; not pinned by a test (should-fix, low).

  6. The seed / import / restore / migration / boot / cascade-outside-a-flow callers keep the exemption by construction: none passes resolveRunDataContext; the mark has one producer. The seed control (SEED_WRITE_EXECUTION_CONTEXT) and the import-shaped insertMany under { isSystem, skipAutomations, skipStateMachine } are pinned green in the new suite. Right.

  7. Marker reachability by an author: none. The key is a Symbol.for registry symbol; no JSON body, query string, flow definition, metadata key or ExecutionContext field can spell a symbol key; keepsFlowWriteReferenceCheck reads the symbol key only (a string key spelled like it matches nothing); ExecutionContextSchema.parse (a zod object) cannot carry it; the one producer is the system arm. An in-process TS caller can import it from @objectstack/core — that caller can already pass isSystem: true, so nothing is granted. Right.

  8. Dev measurement vs tree: the dev's reds (objectql fixtures writing deliberate dangling refs, the stamping fixture, runtime seed pins, the verify harness sys_invitation boot probe) are all non-flow system writers, none marked; not re-run here (read-only), consistent with the producer set in 6.

② Semver level

.changeset/28881-flow-write-reference-check.md: patch on @objectstack/core, @objectstack/objectql, @objectstack/service-automation — the three packages the diff moves (core: new module + exports; objectql: the exit and two docblocks; service-automation: the system arm). Clause-②: no (narrowing) and Metadata-protocol: none on both the PR body and the changeset, line-initial; BREAKING banner present; exactly one ADR-0087 marker (not-required (no-migration-prescription)), fitting: no authorable key is added, removed or renamed, so there is nothing to migrate and no tombstone. AGENTS.md rule 3: the level follows the protocol kind alone — none takes patch, and "a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition". The new core exports are additive and do not move the level under that rule. PR title carries !. Check Changeset check-run: success. Right.

Clause-②: no (narrowing) — matches what the diff publishes: an elevated flow write it admitted (a reference to no row) is now refused with the user path's own answer; nothing is widened.

③ Boundary flags

  • open_questions: [] in the dev report. Nothing to answer.
  • Dev flag — the owner stamp. Every trigger kind that can give a runAs: 'system' run a userId with no sys_user row, read in the tree:
    • schedule / time_relative: user-less (resolveRunDataContext docblock; app-todo and hotcrm schedule flows say so) → stampSystemInsertOwner returns at !userId → no stamp, no refusal. hotcrm contract_renewal / opportunity_stagnation copy owner_id from the live contract/opportunity row; that value is checked and resolves.
    • api (HMAC inbound hook; showcase showcase_inbound_task_webhook): user-less unless the door authenticates a principal; buildAutomationContext takes context.user?.id ?? userId ?? ec.userId (runtime/src/domains/automation.ts). A signed-in user is a better-auth user, whose row IS sys_user (the objectql adapter maps model user → sys_user); an API key "is a credential of its owner, so a key-authenticated context IS the owner's human principal" (ADR-0090 D10, principal-session-mint.ts L33). Both resolve. The webhook body's own assignee / project are caller-supplied and now checked — that is the narrowing itself, not the stamp.
    • anonymous / public endpoint: refused before any run — "No anonymous door may start a runAs: 'system' flow" (ADR-0138 D2b, metadata-protocol publish refusal, both directions) and flowStartRefusal at the endpoint door (endpoint-executor.ts L569).
    • record_change / autolaunched: userId is the writing context's. A user's write → a real account; a platform system write with no userId → user-less; the approvals mirror's { isSystem, userId: decider } → a real account. git grep finds no sentinel userId: 'system' | 'anonymous' | 'svc:…' in any package source.
    • approval resume: the decider (a real account).
    • a deleted trigger user: the one state by design — a run suspended (wait / screen / approval) whose stored triggerUserId (run-resume.ts L332) names a user deleted meanwhile, then a create_record on an object with owner_id. The refusal is then the card's own case (an owner resolving to no account), and no shipped flow reaches it: showcase invoice_signoff / expense_signoff / committee_quorum resume into update_record (the stamp is insert-only), inquiry_purge deletes, hotcrm account_approval resumes into notify only.
      Verdict: no shipped flow in this repo, examples/ or hotcrm main reaches the owner-stamp refusal. Not a blocker. The dogfood on the head (382 files green, dev-reported; the CI dogfood shards are skipped on this PR's run, the summary job success) is consistent.
  • Dev flag — qa seeds not measured separately: seeds never pass resolveRunDataContext; exemption by construction (①.6), pinned by the seed control. Answered.
  • Dev flag — out-of-scope finding (a multiple: true reference keeps a deleted user's id): ruling 4, carried by the seat. Not this PR's. Note it is also the one thing that makes ①.3's cascade leg bite under a flow-run delete.
  • Check-runs on the head at 2026-11-02T06:47Z (42): completed success — Auto Label, Dogfood Regression Gate (summary), Plan Test Core shards, Check Changeset, Lint · PM dispatch-gates, Spec property liveness, Flag docs affected by code changes, Check PR Size, Check Documentation Links, PR-body guards, Governed Surface Queue Guard, filter. Completed skipped — UI Published Artifacts, Temporal Conformance, Dogfood Regression Gate shards, Dogfood Verify CLI, Schedule gate, Packed-tarball smoke, 18.x upgrade harness. Still in progress — Test Core (1–8/8), Live E2E UI, Build UI Deps, Build Docs, Build Core, Type Check · debt ledger / workspace / source gates / consumer gates, Lint · repo & tooling gates / scoped ×3 / CI release & engine gates / ESLint / docs & code guards. No failure read. The derived gate families (type-check, lint, system-context-census under docs & code guards, Test Core) are not concluded at review time; the dev reports all 98 local families exit 0. The seat lands on their conclusions, not on this record.
  • No gate added; no packages/spec/**; ruling 6 held. No assignees on the PR; draft; Fixes #28881; relay-only writes per the report.

④ Design judgment

Judged against the published spec on main (content/docs/permissions/system-context.mdx row 23 and 29c, the ObjectQL#assertReferencesResolve docblock) and the cited code at the head.

Does the narrowing take a declared capability away at the consumer? No. Row 23 on main declares the exemption as a LOSS ("Lose: an isSystem caller can write a dangling reference") for the batch writers that need ordering freedom (seed replay, import, restore, cascade). A flow's runAs: 'system' is declared, everywhere it is documented (ADR-0049 / #1888, ADR-0073 D2, the resolveRunDataContext docblock), as an AUTHORIZATION posture; no spec line grants an elevated flow the right to store a link to a row that does not exist, and the audit sentence the card quotes admits the gap as a gap. The authorization elevation is untouched: the exit keeps isSystem on the context, the middleware short-circuit is not read here, and the elevation control (another member's row, PERMISSION_DENIED on the user path, written by the system flow) is pinned green. So this makes the spec's own refusal (reference_not_found, the user path's) real for the one system caller whose elevation never needed the exemption. The route is ruling 3's: internal mark, one producer, one reader, no key, no error code, no spec.

Attempts to break it, each with a verdict:

  • Does the mark survive to the reader? Yes: symbol key, own property, spread-preserved; opCtx.context is the caller's object; the integration suite drives it end to end over a real kernel. A custom node that hand-builds { isSystem: true } instead of calling resolveRunDataContext keeps the exemption — the documented contract says to call it, and that node already elevates itself; nothing new is granted.
  • Can an author set or clear it? No (①.7).
  • Can a JSON-stored or resumed run lose it? A resumed run rebuilds its data context per node through resolveRunDataContext, so the mark is re-derived; it is never persisted.
  • Tenant scope of the probe: referenceCheckContext keeps the run's tenantId; sys_user is a platform-level object (no organization wall on its own definition), so the trigger user resolves from any organization; a business lookup into another organization is false, as on the user path ([finding] a lookup's existence check reads under a bare system context with no tenant — an org-bound caller can store a reference to another organization's row, and tell "exists elsewhere" from "missing" #19808). Consistent.
  • Nested writes (①.3): judged as the flow's — the precedent's rule, and the user path's behaviour. Not a capability a consumer holds by declaration. But the PR's three records of it are wrong, and one of them ships in CHANGELOG.md.

Findings

  • Should-fix (record truth, ①.3): the changeset's "Unchanged" bullet, the core module header's "Why a symbol" paragraph (the hook-ctx.api-is-parsed premise is false: buildHookApi wraps the raw context) and docs row 23 must say that a write set off UNDER a flow-run write — a hook's ctx.api write, inherit, runAs: 'system' or sudo(), and the cascade's multi-value member-removal leg — carries the mark and is checked as the flow's; a hook fired OUTSIDE a flow run keeps the exemption. If the seat's intent is instead that nested writes keep the exemption, the fix is to strip the mark in buildHookApi / referentialCtx, with a pin. Either way the published words and the tree must agree. Not a must-fix: no shipped hook or flow on main (this repo, examples/, hotcrm — the hotcrm runAs: 'system' hooks copy ids from live rows) writes a reference that does not exist, the dogfood on the head is green, and the behaviour is the user path's own.
  • Should-fix (test breadth, ①.5): one pin for the master_detail arm of a flow-run write (a header that exists nowhere → reference_not_found; an existing header in the run's organization → written), since masterDetailHeaderExists is newly reachable by a system caller and its docblock now claims the read door returns every header.
  • Should-fix (low): the cascade member-removal leg under a flow-run delete_record refuses on pre-existing dangling members (①.3); it is where the out-of-scope finding (deleted ids kept in multiple: true fields) will first surface as a flow failure. Worth one sentence in the docs row and in the out-of-scope card the seat carries.

No must-fix. The tree does what the card asked, by the route the seat ruled, without widening anything.

Implemented-by: claude/project-thread-i6gtvk
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC
Rendered by the seat's subagent.

VERDICT: PASS


Generated by Claude Code

…he check, pin the master_detail arm

A write set off under a flow-run write spreads the run's context, so it
carries the mark and is checked as the flow's own: a hook's ctx.api, its
runAs system or sudo() elevation, and the cascade's member-removal leg.
The core module header, the engine docblocks, the changeset and the
system-context page now say so instead of listing those writers as
exempt. The changeset also names the delete a flow makes whose set_null
leg rewrites a multi-value field still holding a dangling id.

A new pin covers the master_detail arm of a flow-run create: a deleted
header is refused with the user path's answer, a live one is written.

Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-11-02T07:12Z

Head 085ef921, report 6570817610 (on #28881), claim 6568397104. Clause-②: no (narrowing): the contract review is owed and is posted, PASS on 55624b17 (6571011099, served at CONTRACT_REVIEW_TIER, claimed 6570833163), no must-fix.

item reading
Delta 55624b17..085ef921 5 files (+75 / −12), read by the seat. Every changed source line in core/src/security/flow-write-reference-check.ts and objectql/src/engine.ts is inside a comment; the rest is the changeset, docs row 23 of system-context.mdx, and one new pin. It is exactly the review's three should-fixes and nothing else.
S1 The module header, the two objectql docblocks, the changeset and docs row 23 now say that writes a flow-run write sets off (a hook's ctx.api, its runAs: 'system' or sudo(), the cascade's member-removal leg) carry the mark and are reference-checked as the flow's own. The mark is not stripped, as the review preferred.
S2 A master_detail pin: a flow create naming a deleted header is refused with the user path's sentence; a live header is written. Under the ablation 3 of 11 pins red (create, update, master_detail); restore byte-clean.
S3 The changeset names a flow delete_record whose set-null leg meets a multi-value field still holding a dangling id, citing #29062.
Evidence Touched suites green on the head: service-automation 3286, objectql 10040, core 2986; typecheck for all three and eslint. The derived gates were green on 55624b17; this comment/test delta was rerun to 35 of 98 before the maintainer's wrap-up, all exit 0. CI on the head decides; auto-merge waits for it.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z), and 「先确保已有的pr都合并」 (Leehom, 2026-11-02T07:11Z): ready and auto-merge through the relay, with every check green or an expected skip.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 085ef921fcbfde3087b9470acf5d0aa08d4e76bd · 2026-11-02T08:53Z

Taken under references/contract-review.md rule (b): this head has waited over 60 minutes with no record, claim or hand-off on it (the lane's earlier record names an earlier head). At CONTRACT_REVIEW_TIER.


Generated by Claude Code

Merged via the queue into main with commit 9a0c622 Nov 2, 2026
66 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-i6gtvk branch November 2, 2026 08:59
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 085ef921fcbfde3087b9470acf5d0aa08d4e76bd
Local-runs: none
Scope: packages/core/src/security/flow-write-reference-check.ts, packages/core/src/security/index.ts, packages/objectql/src/engine-referential-integrity.ts, packages/objectql/src/engine.ts, packages/services/service-automation/src/runtime-identity.ts, packages/services/service-automation/src/builtin/crud-runas.test.ts, packages/services/service-automation/src/runas-system-reference-check.integration.test.ts, packages/services/service-automation/src/runas-system-stamping.integration.test.ts, packages/services/service-automation/src/runas-user-posture-rung.integration.test.ts + packages/services/service-automation/src/builtin/crud-nodes.ts, packages/services/service-automation/src/builtin/notify-node.ts, packages/services/service-automation/src/plugin.ts (the record expander), packages/services/service-automation/src/run-resume.ts, packages/core/src/security/app-automation-elevation.ts, packages/objectql/src/engine.ts at buildHookApi, referenceExists, referenceCheckContext, masterDetailHeaderExists, createContext and the cascade legs of cascadeDeleteRelations, packages/platform-objects/src/identity/sys-user.object.ts, AGENTS.md rule 3, docs/adr/0087-metadata-protocol-upgrade-contract.md (the marker vocabulary), scripts/check-adr-0087-registration.mjs, .github/workflows/lint.yml and pr-automation.yml (which family runs which gate). The two prose faces (.changeset/28881-flow-write-reference-check.md, content/docs/permissions/system-context.mdx) are read for ② and ④, not reviewed.

Rendered from card #28881 (body + 5 comments: triage#1, the seat's Claim, dev report 6570817610, the shift-close Release, the takeover Claim), PR #29060 (body, 11-file list, the net diff origin/main...085ef921, merge base b15b634c, 11 files / +405 −13), the head's 66 check-runs, and the cited code read with git show prr-29060: / origin/main:. The lane's earlier PASS record (6571011099) is on 55624b17; this head is judged on its own. No worktree, no build, no test, no gate re-run.

① Derived judgments

Every accept-set and public-surface change the diff implies, each judged:

  1. A runAs: 'system' flow's create_record / update_record (by id and by predicate, multi: true included) naming a lookup, user or master_detail id that resolves to no row in the run's organization: admitted → refused VALIDATION_FAILED with the field's reference_not_found, carried as the node's innerRefusal. Right, and it is the card's ask. One producer: the system arm of resolveRunDataContext (runtime-identity.ts, the markFlowWriteReferenceCheck wrap); one reader: the early exit of assertReferencesResolve (engine-referential-integrity.ts L94, isSystem && !keepsFlowWriteReferenceCheck(context)). Every writing node in service-automation reaches the engine through that arm (crud-nodes.ts L533 get, L630 create, L784 update, L882 delete; notify-node.ts reads only), the engine passes opCtx.context untouched to all three call sites (engine.ts L16345 insert, L18157 by-id update, L18583 predicate update), and everything after the exit is the user path's check unchanged. Same words, same field code, so no new error code.

  2. Authorization elevation unchanged. The marked context keeps isSystem: true; the middleware short-circuit is not read here; the probe runs under referenceCheckContext ({ ...context, isSystem: true }, tenant and transaction kept). The elevation control in the new suite (a member's own update of another member's row is PERMISSION_DENIED; the same system flow's update succeeds) pins it. Right.

  3. The owner stamp is checked too. stampSystemInsertOwner writes owner_id = trigger user INTO fields before data.insert, so it is caller-supplied in the check's sense. A system flow whose trigger user has no sys_user row is refused on insert with Owner: no sys_user record has id …. Right: that is exactly the card's measured case (an owner that resolves to no account), the changeset and docs row 23 name it, and runas-system-stamping now seeds the member row. Trigger-kind walk in ③.

  4. Writes a flow-run write sets off carry the mark. buildHookApi wraps the RAW opCtx.context in ScopedContext (engine.ts L8261, no parse); sudo() and withRunAs spread it; the cascade legs build referentialCtx = { ...context, __referentialFieldClear… } (L20066). So a hook's ctx.api write under a flow write, its runAs: 'system' or sudo(), and the multi-value member-removal leg of a flow's delete_record are checked as the flow's own; the set_null leg writes null, which is not a reference. The APP_AUTOMATION_ELEVATION precedent's own rule, and the user path's behaviour. On this head the module header, both objectql docblocks, the changeset and docs row 23 say exactly this (the earlier record's wrong-premise finding is closed). Right.

  5. Public surface. @objectstack/core gains three additive exports (FLOW_WRITE_REFERENCE_CHECK, markFlowWriteReferenceCheck, keepsFlowWriteReferenceCheck), the exact shape of the precedent; resolveRunDataContext's system arm returns one extra own symbol key, pinned by toEqual in five unit cases, and the unchanged user-arm pin says a user run never carries it. No packages/spec/**, no schema, no error code. Right.

  6. No new authorable key. The key is a Symbol.for registry symbol: no JSON body, query string, flow definition, metadata key or ExecutionContext field can spell it, keepsFlowWriteReferenceCheck reads the symbol key only, and ExecutionContextSchema.parse (zod, declared keys only) drops it, as the module header says. An in-process TS importer can set it, and that caller can already pass isSystem: true, so nothing is granted. triage#1's ⚠️ (an authorable key is a protocol addition) does not fire. Right.

  7. Every other system writer keeps the exemption by construction: seeds, import, restore and migration, boot provisioning, cascade and set-null outside a flow, hooks and action bodies no flow write set off. None passes resolveRunDataContext; the seed control (SEED_WRITE_EXECUTION_CONTEXT) and the import-shaped insertMany are pinned green. The record expander in plugin.ts and the notify node's two { isSystem: true } reads are reads. Right.

  8. masterDetailHeaderExists is newly reachable by a system caller (a flow run's write only): the read door returns every asked id for isSystem, then referenceExists under the run's tenant. Pinned on this head (a deleted header refused with the user path's sentence, a live header written). Right.

② Semver level

.changeset/28881-flow-write-reference-check.md: patch on @objectstack/core, @objectstack/objectql, @objectstack/service-automation, the three packages the diff moves. Line-initial Clause-②: no (narrowing) and Metadata-protocol: none on both the PR body (lines 6–7) and the changeset body. AGENTS.md rule 3: the level follows the protocol kind alone, none takes patch, and "a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition". The BREAKING banner is present, the PR title carries !, and the body carries exactly one marker, adr-0087: not-required (no-migration-prescription), which fits: no key of any schema is added, removed, renamed or re-shaped, there is no FROM/TO block (the "remedy" sentence is prose, not an arrow or framed table), and the gate that re-verifies the category (check-adr-0087-registration.mjs, PR-body guards / Check Changeset) is green on the head. Metadata-protocol: none is right: nothing a metadata author can write changes. Right.

Clause-②: no (narrowing) — matches what the diff publishes: a write an elevated flow admitted (a reference to no row) is now refused with the user path's own answer; nothing is widened.

③ Boundary flags

  • open_questions: [] in the dev report. Nothing to answer.
  • Dev flag — the owner stamp. Trigger kinds that can hand a runAs: 'system' run a userId naming no sys_user row: schedule / time_relative are user-less (stampSystemInsertOwner returns at !userId); record_change / autolaunched carry the writing context's userId (a real account, or none for a platform system write); api with a signed-in or API-key principal resolves to its sys_user owner; anonymous doors may not start a system flow (ADR-0138 D2b, flowStartRefusal); approval resume carries the decider. git grep finds no sentinel userId literal ('system', 'anonymous', svc:) in any non-test package source. The one reachable state is a suspended run whose stored triggerUserId (run-resume.ts L332) names a user deleted meanwhile, resuming into a create_record on an object with owner_id: that refusal IS the card's case, and no shipped flow in this repo or examples/ resumes into an insert. Answered; not a blocker.
  • Dev flag — qa seeds not measured separately: exemption by construction (①.7), pinned by the seed control. Answered.
  • Dev flag — out-of-scope finding (a multiple: true reference keeps a deleted user's id, data: a multi-user field keeps a deleted user's id, so a later update carrying the field unchanged is refused reference_not_found #29062): ruling 4, carried by the seat; the changeset names where it first surfaces under this narrowing (a flow's delete_record whose member-removal leg rewrites a stale member). Answered.
  • Check-runs on 085ef921: 66, all completed. 58 success, including Check Changeset, PR-body guards, Governed Surface Queue Guard, Lint · docs & code guards (check:system-context-census), Lint · CI, release & engine gates, Lint · PM dispatch-gates, Spec property liveness, Type Check · debt ledger / workspace / source gates / consumer gates, Test Core 1–8/8, Build Core, Build Docs, Live E2E UI, Test UI 1–16/16. 8 skipped, each an expected skip: Lint UI, Dogfood Regression Gate shards (the summary job is success), Dogfood Verify CLI, Temporal Conformance, UI Published Artifacts, the Schedule gate, and the two opt-in harnesses. Nothing pending, nothing failed.
  • No gate added; no packages/spec/**; the seat's ⛔ (no authorable key, no new error code) held. Fixes #28881 opens the body.

④ Design judgment

Judged against the published spec on main (content/docs/permissions/system-context.mdx rows 23 and 29c, the ObjectQL#assertReferencesResolve docblock, ADR-0049 / ADR-0073 D2 on runAs) and the cited code at the head.

Does the narrowing take a declared capability away at the consumer? No. Row 23 on main declares the exemption as a LOSS ("an isSystem caller can write a dangling reference") for the batch writers that need ordering freedom; the audit sentence the card quotes admits the gap as a gap. runAs: 'system' is declared everywhere as an AUTHORIZATION posture (ADR-0073 D2, the resolveRunDataContext docblock); no spec line grants an elevated flow the right to store a link to a row that does not exist. So the change makes the spec's own refusal (reference_not_found) real for the one system caller whose elevation never needed the exemption, by the route the seat ruled: internal mark, one producer, one reader, no key, no error code, no spec.

Attempts to break it:

  • Can the mark be lost before the reader? No for every shipped node: the CRUD nodes pass dataCtx straight as options.context; the engine passes opCtx.context to the check; a subflow / map child's nodes re-derive their context through the same arm; a resumed run re-derives it per node (never persisted). The only parse (createContext) is not on the flow path. A custom node that hand-builds { isSystem: true } instead of calling resolveRunDataContext keeps the exemption, but that node already elevates itself and the published contract says to call the helper: nothing new is granted.
  • Can an author set or clear it? No (①.6).
  • Can the mark grant anything? No: its only reader narrows. The probe it keeps running is the same elevated, tenant-kept referenceExists the user path uses; sys_user has no organization column, so the trigger user resolves from any organization, while a business lookup into another organization is false, as on the user path ([finding] a lookup's existence check reads under a bare system context with no tenant — an org-bound caller can store a reference to another organization's row, and tell "exists elsewhere" from "missing" #19808). A user-less system run (a schedule, no tenantId) probes across organizations, which is already its declared posture. No cross-tenant disclosure beyond the id the caller itself supplied; no security note is owed.
  • Does elevation still work? Yes (①.2), pinned.
  • Record truth on this head: the module header, both objectql docblocks, the changeset and docs row 23 agree with the tree on nested writes and on the cascade leg; check:system-context-census is green on the row edit.

Findings

  • Should-fix (test breadth, low): no pin drives a hook's ctx.api write under a flow-run write, the arm the changeset and docs now describe. One integration case (a beforeInsert hook on the flow's target that writes a dangling lookup through ctx.api, refused reference_not_found) would pin the described behaviour instead of leaving it to the precedent's reasoning.
  • Should-fix (docs, low): resolveRunDataContext's own docblock still describes the system arm as { isSystem: true, actor: 'svc:flow:…' } and does not mention the mark; the inline comment does. One line there keeps the single-place contract the docblock claims to be.

No must-fix. The tree does what the card asked — integrity is checked for a flow's system writes, authorization elevation is unchanged, an elevated flow that writes a dangling id gets the user path's loud refusal with its remedy, no migration is owed, no authorable key was added — by the route the seat ruled, without widening anything.

Implemented-by: claude/project-thread-i6gtvk
Reviewed-by: session_01AavokzJ5DndAwitDXvKy4U

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant