Repository navigation
fix(objectql)!: a runAs system flow's record writes keep the reference check while keeping their authorization elevation (#28881) - #29060
Conversation
…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>
📓 Docs Drift CheckThis PR changes 3 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 51 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC Contract-review-claim: Taken by the dispatching seat. Face Generated by Claude Code |
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC Contract reviewServed-tier: 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 ① Derived judgmentsAccept-set changes the diff implies, each judged:
② Semver level
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
④ Design judgmentJudged against the published spec on Does the narrowing take a declared capability away at the consumer? No. Row 23 on Attempts to break it, each with a verdict:
Findings
No must-fix. The tree does what the card asked, by the route the seat ruled, without widening anything. Implemented-by: 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>
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC 席位核对 · ACCEPT ·
|
| 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
|
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U Contract-review-claim: Taken under Generated by Claude Code |
|
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U Contract reviewServed-tier: Rendered from card #28881 (body + 5 comments: triage#1, the seat's Claim, dev report ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each judged:
② Semver level
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
④ Design judgmentJudged against the published spec on Does the narrowing take a declared capability away at the consumer? No. Row 23 on Attempts to break it:
Findings
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: VERDICT: PASS Generated by Claude Code |
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.mdnamesreference_not_foundonly for the grant catalog, which this does not touch. The one docs page that states the exemption,content/docs/permissions/system-context.mdxrow 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的同一套先例。packages/spec/**。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
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)andkeepsFlowWriteReferenceCheck(context). It followsAPP_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.runtime-identity.ts). Only in therunAs: 'system'arm ofresolveRunDataContext, which every CRUD node and custom node reaches through. The user arm never carries it (crud-runas.test.tspins both arms withtoEqual, which compares symbol keys).engine-referential-integrity.ts).if (context?.isSystem) return;becomesif (context?.isSystem && !keepsFlowWriteReferenceCheck(context)) return;. Everything after it is the user path's check unchanged: only caller-supplied values, probed under the existingreferenceCheckContext(tenant kept), and the master-detail header read, which for a system caller returns every header in its organization.stampSystemInsertOwnerfillsowner_idfrom the trigger user into the written fields, so it is now checked too. A system flow whose trigger user has nosys_userrow is refused on insert. That is the case the card describes (an owner that resolves to no account), and the changeset names it.ObjectQL#assertReferencesResolveandmasterDetailHeaderExistsupdated;system-context.mdxrow 23 names the one system caller that keeps the check..changeset/28881-flow-write-reference-check.md:patchon core, objectql and service-automation, both declaration lines, the ADR-0087not-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.@objectstack/objectqlengine-dangling-reference-audit,engine-lookup-referential-integrity,engine-reference-tenant-scope,engine-multi-reference-element-shape,engine-formula-hop-stored-metadata-body.@objectstack/service-automationrunas-system-stamping: the flow's owner stamp names trigger userusr_member, which the fixture never inserted as asys_user. A fixture gap, fixed in this PR by seeding the row.@objectstack/runtimeseed-blank-reference-real-driver, the external-object spike, two stored-metadata pins, the unresolved-object-name pin.sys_invitationnamingorg_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.assigneeis a lookup tosys_user, andusr_deletedis inserted and then deleted.create_recordnaming the deleted user: the run fails,innerRefusalequals{ code: 'VALIDATION_FAILED', message }wheremessageis the user path's own sentence for the same write (Assignee: no sys_user record has id "usr_deleted", field codereference_not_found), and no row exists.update_record: refused the same way, and the row keeps its assignee.PERMISSION_DENIED; the same system flow's update of it succeeds.SEED_WRITE_EXECUTION_CONTEXT) and an import's batched create (insertManyunder{ isSystem, skipAutomations, skipStateMachine }, the context the import door builds) both still storeusr_not_yet_created.origin/main, tests kept, core and objectql rebuilt withpnpm --filter)Tests 2 failed, 8 passed (10)over the new file andrunas-system-stamping: red are create and update; both controls and stamping green.Tests 10 passed (10)Ablation
From committed HEAD,
&& !keepsFlowWriteReferenceCheck(context)) return;replaced by a guarded form (markerABL_M, counted in dist), objectql rebuilt:Tests 2 failed, 8 passed (10), exactly create and update. Restored: blob equals HEAD,git diff HEADempty, rebuilt dist has 0 markers.Suites, typecheck, lint, dogfood and gates
pnpm build@objectstack/objectql@objectstack/core@objectstack/runtime@objectstack/service-automation09dbbaebdispatch-gates.mjs --commands(98 families) pluscheck:type-check-debt55624b17(67 on the code diff, 31 more once the docs page joined it), pluscheck:type-check-debtexit 0;--ran: 98 run, 0 NOT-MEASURED.check:system-context-censusexit 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