feat(spec): declare AutomationContext.recordLoadDenied, the flow face of the caller-scope record-load signal - #15143
Conversation
… of the record-load signal
Additive key `recordLoadDenied?: true` on `AutomationContext`, mirroring the
producer's spelling (`actionRecordLoadSignal` in the runtime's
`action-execution.ts` returns `{ recordLoadDenied?: true }`), with a type-level
pin (`true | undefined`, additive positive control, `false` refused) and a
minor changeset. Declared, not yet populated on the flow face — the runtime
half is a separate card.
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
Co-authored-by: Claude <noreply@anthropic.com>
…tomationContext, not yet populated Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i Co-authored-by: Claude <noreply@anthropic.com>
…t-record-load-denied Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 128 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 d64a24795b8306a2fc0ba434aacb74fd551c4c52 && git checkout d64a24795b8306a2fc0ba434aacb74fd551c4c52
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fcc42e6c1de9df5c65ab60166bf15c3aeea5bfe2 6c14ba889912b6d7b11c9fd9add651cb291925bc && git checkout -B drift-repro fcc42e6c1de9df5c65ab60166bf15c3aeea5bfe2 && git merge --no-ff 6c14ba889912b6d7b11c9fd9add651cb291925bc
node scripts/docs-audit/affected-docs.mjs --json fcc42e6c1de9df5c65ab60166bf15c3aeea5bfe2
|
Contract review (Clause ②) — PASS · ACCEPTReviewer of record: the Head and window (readings at 2026-09-04T03:23Z)
The widening, verified in the diff
Report checklist
Seat ruling on the report's open question (open to the maintainer's veto)Runtime half — A first: context-only, mirroring the handler face exactly; the engine (or a Follow-up card this seat files at landing
LandingAt all-green on
Generated by Claude Code |
|
Landing provenance — Landing window readings, all on head
Sequence: ready → squash auto-merge → Generated by Claude Code |
Fixes #14244
The ruling (inherited from #14143, quoted where operative)
Triage 5502970354 (2026-09-02):
Unlock scan 5503608656 (2026-09-02):
Clause-②: yes —
AutomationContextis a published@objectstack/speccontract, so this PR widens a published surface;needs:contract-reviewis hung on it for the in-seat contract review. No semantic fork found: the additive key is viable, nothing requires refusing the start, anddispatchFlowActionis not narrowed.Scope: the spec half only
This PR declares the key on the contract, pins its shape, documents it, and ships a changeset. The runtime half —
dispatchFlowActionand the REST/actionsdoor actually passing the producer's signal into the run's context — is a separatedomain:clifollow-up card the PM files from the read-and-report at the end of this body. ⛔packages/runtime/**,packages/services/**, objectui andcontent/docs/releases/**are untouched.Premise re-verified on origin/main (
2cc46103, the branch base)packages/runtime/src/action-execution.ts:1278:export function actionRecordLoadSignal(load: ActionSubjectRecordLoad): { recordLoadDenied?: true }(body at:1279:return load.recordLoadDenied ? { recordLoadDenied: true } : {};). Built byloadActionSubjectRecordat:1245–:1270(recordLoadDenied = trueat:1260and:1263, returned at:1269).packages/spec/src/contracts/automation-service.ts:25:export interface AutomationContext {declaresrecord/previous/object/event/userId/positions/permissions/tenantId/runAs/flowName/flowRunId/paramsand norecordLoadDenied(git grep -n recordLoadDenied packages/spec/src→ 0 hits before this PR). Premise holds.content/docs/ui/actions.mdx:341–:371("Read it asctx.recordLoadDenied === true. The key is absent, neverfalse, when nothing was refused") andcontent/docs/automation/hook-bodies.mdx:241.What changed (4 files, +201)
packages/spec/src/contracts/automation-service.ts—recordLoadDenied?: true;onAutomationContext, directly underrecord: optional, literaltrue, absent otherwise, neverfalse— exactly the producer's shape. The TSDoc states what it means (the dispatcher's caller-scope load of the subject row did not deliver it andrecordis the stamped id stub), who sets it (the runtime's action dispatch, both doors — RESTPOST /api/v1/actions/...and the MCPrun_actionbridge — through the one producer, cited by file and line on2cc46103), what a flow guards on (runAs: 'system'flows especially; arunAs: 'user'flow re-derives the caller's scope and the stub resolves to nothing), what it does not claim (an RLS-hidden row and an id that names nothing both arrive asRECORD_NOT_FOUND, deliberately), and — plainly — that it is declared, NOT YET POPULATED on the flow face until the runtime half lands.packages/spec/src/contracts/automation-context-record-load-denied.pin.test.ts(new, beside the family'sautomation-result-status.pin.test.ts, sameEq/Assertform) — type-level identityAutomationContext['recordLoadDenied']≡true | undefined; identity with the producer's{ recordLoadDenied?: true }; an additive positive control (a context literal without the key type-checks);@ts-expect-erroronrecordLoadDenied: false; a runtime anti-vacuity read; a service double guarding with=== truewithout a cast; and a JSDoc-reading pin requiring the doc block to nameloadActionSubjectRecord,actionRecordLoadSignal, both doors,run_action,runAs: 'system',dispatchFlowAction, "neverfalse" and NOT YET POPULATED — so the day the runtime half lands, the test names the sentence to retire. It is inside the compiled test program:tsconfig.test.jsonincludessrc/**, andtest-typecheck-debt.jsonlists the file nowhere, socheck:test-typecheckrequires it to compile with zero errors (it does).content/docs/ui/actions.mdx— "Authorization inside an action" gains a Flow actions paragraph: same key, same spelling, declared onAutomationContext, declared but not yet populated on the flow face; arunAs: 'user'flow needs no guard.node scripts/docs-audit/affected-docs.mjsat the final head lists exactly two pages: this one (edited) andcontent/docs/releases/v15.mdx(release-owned — ⛔ never edited in a code PR). No hand-written page listsAutomationContext's keys; outside the generatedreferences/tree the name appears only in ADRs, CHANGELOGs, READMEs and changesets..changeset/automation-context-record-load-denied.md—@objectstack/spec: minor(additive public-surface key). ADR-0087 marker: none owed —scripts/check-adr-0087-registration.mjsjudges only a changeset that declares a breaking change (itsbreakingDeclaration()matches**BREAKING**/major/feat!:); aminoradditive key declares none, so none of the five marker spellings is used. Its--self-testandcheck-changeset-no-major --self-testare in the gate union below.Generated products:
pnpm --filter @objectstack/spec check:generated→ "✓ All 15 generated artifacts are up to date." on the first run;--fixregenerated nothing (an optional interface member moves neitherapi-surface/— "@objectstack/spec public API surface + factory signatures unchanged ✓" — norexport-origins/,declaration-map/, norcontent/docs/references/**, which does not render contract interfaces); confirming run at6c14ba889exit 0, same line.Measurements at
6c14ba889(git rev-parse --short HEADafter the last commit; every exit captured by redirect before any pipe; verdict lines quoted from the gate's own output)os-verify-lock.sh -c 'pnpm --filter @objectstack/spec build'check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present.·os-verify-lock: VERDICT command-exit 0 · held the lock 102sos-verify-lock.sh -c 'pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/contracts/automation-context-record-load-denied.pin.test.ts'Test Files 1 passed (1)·Tests 3 passed (3)·VERDICT command-exit 0os-verify-lock.sh -c 'pnpm --filter @objectstack/spec typecheck'(tsc --noEmit, check:scripts-typecheck, check:test-typecheck)check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json; 54 file(s) / 261 error(s) / 145 pinned signature(s) held in test-typecheck-debt.json·VERDICT command-exit 0pnpm --filter @objectstack/spec check:generated✓ All 15 generated artifacts are up to date.· exit 0 (first run,--fix, and the confirming run at the final head)node --stack-size=4000 node_modules/eslint/bin/eslint.js --no-inline-configover the two TS filespnpm lint(foreground)eslint . --no-inline-config, 59s wall clock on the shared box) — no narrowing to provepnpm check:doc-authoring✓ doc authoring guard: 393 files clean — no bare metadata literals.(+ 3 more ✓ lines) · exit 0pnpm check:nul-bytescheck-nul-bytes: OK (scanned 8232 text file(s) -- 8232 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).· exit 0os-verify-lock.sh -c "pnpm turbo run typecheck --filter=@objectstack/runtime --concurrency=2"(closure built via^build)@objectstack/runtime:typecheck: cache miss, executing·check:test-typecheck: OK — @objectstack/runtime's test layer compiles under packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned signature(s)·Tasks: 30 successful, 30 total·VERDICT command-exit 0 · held the lock 270s— unaffectedos-verify-lock.sh -c "pnpm --filter @objectstack/service-automation typecheck"(direct, cache-bypassing)typecheckscript (ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT; it is ledgered type-check debt). Rawnpx tsc --noEmit --pretty falseat6c14ba889under the lock: 3 errors, allsrc/nested-region-parity.test.ts: error TS2341: Property 'flows' is private and only accessible within class 'AutomationEngine'., none namingrecordLoadDeniedorAutomationContext; the base-commit count was not measured, so this is a raw reading, not a verdictnode scripts/check-system-context-census.mjs(after the last edit)check-system-context-census: OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read.· exit 0node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsgate list derived from the tree of 'objectstack-ai/objectstack' at commit 6c14ba889· change set 4 paths vs merge basee37456eb0(committed 4, working tree 0, untracked 0) · 81 commands — see belowGate union (81 commands at
6c14ba889): 79 exit 0 at the final state, 2 NOT MEASURED, reported in their own words:pnpm check:dual-build-cjs-loads→ exit 3: "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. … Runpnpm buildfirst. ⛔ This is NOT a pass: nothing was measured." (38 packages are unbuilt in this worktree; CI's full build owns it.)pnpm check:type-check-debt→ exit 3: "check-type-check-coverage: PREREQUISITE NOT MET … ⛔ This is NOT a pass and NOT a finding: nothing was measured" (same reason: measures with every package's closure built).check:skill-examplesneeded@objectstack/client-reactbuilt (built under the lock, then "✅ 257 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them", exit 0);check:api-surface,check:browser-reachable-entries,check:dual-source-exports,check:entry-nameabilityreported "packages/spec/dist/**/*.d.ts is OLDER than packages/spec/src" because the reverse-verification touch (below) had left the source file newer thandist(bytes identical, hash-proven) — the mtime probe is working as designed (gen:api-surface / gen:export-origins read the dist without asserting it is FRESH — a stale dist launders a phantom breaking removal into the committed baseline #7122); spec was rebuilt and all four re-ran green ("@objectstack/spec public API surface + factory signatures unchanged ✓").check:react-declaration-parity—check:generatedreports it "Cannot run here" (needs objectui's manifest); unchanged posture.Reverse verification (one leg, from the committed state at
6c14ba889)Precondition
git status --porcelainempty. Mutation: delete only therecordLoadDenied?: true;line (TSDoc kept, so the JSDoc-reading pin fails on the missing declaration, not on missing prose). Landed on disk:grep -c 'recordLoadDenied?: true;'1 → 0;git diff --stat:1 file changed, 1 deletion(-).tsc --noEmit --pretty false -p tsconfig.test.json→ exit 2 (the whole test program, which always carries the ledgered debt); the pin file contributes 7 new errors:error TS2339: Property 'recordLoadDenied' does not exist on type 'AutomationContext'.×6 (lines 53, 57, 68, 69, 77, 79) anderror TS2353: Object literal may only specify known properties, and 'recordLoadDenied' does not exist in type 'AutomationContext'.(line 62). Undercheck:test-typecheckan unledgered file with errors is red.vitest runon the pin →Tests 1 failed | 2 passed (3): the JSDoc-reading pin fails withAssertionError: expected -1 to be greater than -1(declaration string absent). The two runtime-only tests stay green by design — esbuild strips types; the type-level pins are tsc's.trap:git checkout HEAD -- ABSOLUTE_PATH(index and tree) exit 0. Proof:git hash-objectof the file =1bb555b762fbdd2a5db25e1f98d64acb419a93ac=git rev-parse HEAD:packages/spec/src/contracts/automation-service.ts;git diff HEAD --statempty;git status --porcelainempty; declaration count back to 1.Deviations
origin/main(e37456eb0, one commit:packages/metadata-protocol+scripts/engine-double-contract.pinned.json+ its changeset — none of this PR's files, nothing underpackages/spec,packages/runtimeorpackages/services) before opening, becausedispatch-gatesflagged the pinned ledger as a stale derivation input; the final head is the merge commit and every measurement above was taken there. No conflicts.^buildbuilds the closure); the service-automation typecheck was additionally run directly because turbo answered it from the shared.turbo/cache(a replay is not a measurement of this tree).Read-and-report for the runtime follow-up card (no edits; lines on
2cc46103, runtime and services unchanged by the merge)Where
dispatchFlowActionreceivesrecord.packages/runtime/src/action-execution.ts:791—wiring: { objectName; record; params; recordId?; ec; envId? }(:794–:801); it handsautomation.execute(action.target, {…})the context literal at:820–:828:record,object(unless object-less),userId,positions,permissions,tenantId,params: seedFlowActionParams(…)(:827). No load signal anywhere in that literal.Where the producer's
recordLoadDeniedis in scope at each call site.run_actionarm:packages/runtime/src/domains/mcp.ts:649→actionExec.invokeBusinessAction(...)(action-execution.ts:1300); inside itconst subject = await loadActionSubjectRecord(objectName, recordId, …)at:1383,const record = subject.record;at:1385; the flow branchif (action.type === 'flow')at:1406callsdispatchFlowAction(deps, requestContext, action, { objectName, record, params, recordId, ec, envId })at:1407—subjectis in scope and its signal is dropped; the handler face two branches down spreads...actionRecordLoadSignal(subject)at:1435./actionsdoor:packages/runtime/src/domains/actions.ts:623const subject = await actionExec.loadActionSubjectRecord(…),:625const record = subject.record;; the handleractionContextspreads...actionExec.actionRecordLoadSignal(subject)at:657; the flow branchif (actionType === 'flow')at:682callsactionExec.dispatchFlowAction(deps, _context, actionDef, { objectName, record, params: reqParams, recordId, ec, envId })at:685— same drop.dispatchFlowAction'swiringwith theActionSubjectRecordLoad(or its signal) and spread...actionRecordLoadSignal(load)into the context literal at:820— never ontorecord, and neversubject.recordLoadDeniedraw (ActionSubjectRecordLoad.recordLoadDeniedat:1203is the internalboolean; the optional-trueform is the contract).Does
seedFlowActionParamscopyrecordkeys intoparams? Yes —action-execution.ts:618;const seeded = { ...record };at:628copies everyrecordkey, then the id aliases, thenreturn { ...seeded, ...params };at:661. So the key leaks intoparamsonly if it is put onrecord; carried as a sibling context key (as the producer'sactionRecordLoadSignalalready does on the handler face) it never reachesparams— and therefore never reachesseedDeclaredVariables'isInputbindings either.What
automation.execute(target, ctx)does with an unknown context key today.packages/services/service-automation/src/engine.ts:4082: it readscontext?.recordfor the re-entrancy key (:4130);seedRunVariables(:8176) seeds onlyrecord(and its flattened fields),previous,$runId/$flowName/$flowLabel, on top ofseedDeclaredVariables(:8108, which readscontext.params[v.name]forisInputvariables) — an unknown context key is never seeded into the variable environment, so no start-nodeconditionor template can see it until the engine seeds it deliberately;resolveRunContext(:3936) spreads...(context ?? {})at:3943into the run context — carried verbatim intoresolveRunDataContextand into persistence:suspended-run-store.ts:654and:704writecontext_json: JSON.stringify(run.context ?? {}), restored at:672/:752. Today, then: carried and persisted, read by nothing. The follow-up owes a decision on whether the flow face reads it from the context (a runAs-aware guard in the engine) or seeds it as a variable so authors can guard in CEL — the contract as declared supports either.Do any flow-side test doubles or fixtures construct
AutomationContextin a way a new optional key breaks? No.git grepoverpackages/forkeyof AutomationContext,Required/Omit/PickofAutomationContext, andsatisfies AutomationContext→ 0 hits. Literal constructions:service-automation/src/builtin/crud-runas.test.ts:145(const trigger: AutomationContext = { userId: 'u1' }),loop-dying-body-steps.test.ts:161/:391({ event: 'schedule' } as AutomationContext),map-node.ts:111/:156/:172andloop-node.ts:98/:137({} as AutomationContextfallbacks),qa/dogfood/test/flow-runas-schedule.dogfood.test.ts:40,runtime/src/endpoint-executor.ts:536(cast frombuildAutomationContext, which returns a plain record —domains/automation.ts:80). All optional-key tolerant; confirmed by the@objectstack/runtimeand@objectstack/service-automationtypechecks above.Not in this PR
content/docs/releases/**edits; ⛔ no narrowing ofdispatchFlowAction; ⛔ no claim thatrunAs: 'system'is wrong.ctx.record.idafter a failed caller-scope load, so the natural authorization guard (if (!ctx.record?.id) refuse()) is always true on a row the caller cannot read #14143 is not re-addressed here (it landed via PR fix(runtime): tell an action handler when its caller-scope record load was refused #14247); this PR is the flow-face contract only.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
Generated by Claude Code