Repository navigation
runtime: carry the caller-scope record-load signal into a flow action's context — both doors (#15168) - #15304
Conversation
…action's context WIP checkpoint before the verification sweep. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift CheckThis PR changes 2 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 — 134 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 468a8c54261e281a2866be5d44629b1fa9bc8995 && git checkout 468a8c54261e281a2866be5d44629b1fa9bc8995
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5b09356b75a06615e705b432bc1a9ddc70e37e4a 5ebf3cb31cf2235a4cc131f01ffbb1ac1796f607 && git checkout -B drift-repro 5b09356b75a06615e705b432bc1a9ddc70e37e4a && git merge --no-ff 5ebf3cb31cf2235a4cc131f01ffbb1ac1796f607
node scripts/docs-audit/affected-docs.mjs --json 5b09356b75a06615e705b432bc1a9ddc70e37e4a
|
Landing — contract review PASS at
|
Fixes #15168
The runtime half of #14244, whose contract half landed in PR #15143.
AutomationContext.recordLoadDenied?: truewas declared and nothingpopulated it on the flow face:
dispatchFlowActionhandedautomation.executea context without it, so arunAs: 'system'flow thatguarded on the documented key was inert — never
true, never wrong, andindistinguishable from a flow whose caller could read the row. The stamped
record.idis present either way (new-record / record-less actions depend onthat stamp), so there was nothing else on the run's context to guard on.
This populates it, on both doors in one stroke — REST
POST /api/v1/actions/...(domains/actions.ts) and the MCPrun_actionbridge (
action-execution.ts).The shape
record, never a key on it. A flow node readingrecord.recordLoadDeniedfinds nothing; a pin asserts'recordLoadDenied' in ctx.record === falseon the denied path.false. The producer already returns exactly{ recordLoadDenied?: true }, so the spread is what makes the key absentwhen nothing was refused — matching the contract's
true | undefinedand thehandler face's
recordLoadDenied === trueconvention.acts on it — a flow-level refusal, a step condition — is a separate reading
and is deliberately not here.
One shape decision worth reviewing: the wiring takes
subject, notrecorddispatchFlowAction's wiring changes fromrecord, a plain string-keyed record, tosubject: ActionSubjectRecordLoad, and the door derivesrecordand thesignal from that one outcome. The card allowed either this or passing the
already-computed signal object; this is the stronger of the two:
subjectand were passingsubject.recordout of it, so
recordin the wiring was a second de-facto source forsomething the producer already owns. Taking the whole outcome removes it
instead of adding a key beside it.
forward the row while dropping the verdict — which is precisely the failure
action dispatcher stamps
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 was filed for, one door over.dispatchFlowActionis not re-exported frompackages/runtime/src/index.ts, and the package's only entry is.→dist/index.js, so the wiring is internal to the package. Verified by grepover
index.tsand theexportsmap.One in-repo caller outside the two doors exists — the direct-call case in
actions-flow-dispatch-status.test.ts— and is updated in the same commit;it is a pin of
dispatchFlowActionitself.Point 4 — the retired sentence
packages/spec/src/contracts/automation-service.tscarried "Declared, NOT YETPOPULATED on the flow face…". That is now the populated statement, and
automation-context-record-load-denied.pin.test.ts— the pin that named thesentence so its author would be told what to retire — asserts both
directions: the populated wording is present and
NOT YET POPULATEDis gone.A positive alone would survive a doc asserting both things at once; a negative
alone is satisfied by deleting the paragraph, which is how a contract loses the
record of who populates a key.
This is the ONE
packages/specedit pre-authorised on the card (plus its pin).Nothing else under
packages/spec/**is touched.Tests — all runs at
5ebf3cb3, this PR's headSeven new pins in
packages/runtime/src/action-record-load-denied.test.ts,sharing that file's existing row-scoped engine double (
crm_case:case_1isvisible to its owner and to nobody else) and the REAL
callData, so therefused/absent collapse is not mocked away:
Two ablations, each proved on disk and restored from
HEADThe marker
...actionRecordLoadSignal(subject),occurs twice inaction-execution.ts(the flow door and the script-body door), so eachmutation is proved by a count delta plus the injected marker, not by a
presence test, and by a blob-hash difference against the
HEADblob. Everyrestore is proved by
git hash-objectequalling theHEADblob and by anempty
git diff HEADfor that path. Direction was predicted before each runand both matched:
Tests 3 failed / 17 passed— exactly those threerecordLoadDenied: subject.recordLoadDenied(i.e.falsewhen readable — the likeliest wrong implementation)Tests 5 failed / 15 passed— exactly those fiveTests 20 passed (20)The asymmetry is the point: neither the absent-not-
falsepins nor the deniedpins can be satisfied by the other's implementation, and each set is the
other's firing control. No rebuild step applies — the tests import
./action-execution.jsrelatively, resolving tosrc, with nodiston thepath (
KNOWN_UNALIASED_TEST_IMPORTSuntouched;check:test-source-aliasgreen).
Gates
Derived on a clean tree at this head, never hand-written:
All 63 run, plus the 6 artifact-roster families the derivation flags as ⛔
(their roster sits under a directory one of these paths is in). Each exit code
captured before any pipe. 69 of 69 green, 0 red, 0 unmeasured.
Two of them first returned exit code 3 — which both gates define as their
own PREREQUISITE NOT MET, neither pass nor finding — because they read built
output this worktree did not yet have:
check:dual-build-cjs-loads: "this gate reads built output, and some packagehas no
dist/… ⛔ This is NOT a pass: nothing was measured."check:type-check-debt:--re-measurerefuses without the built closure,because measuring there would "silently measure a DIFFERENT WORLD".
So the prerequisite was satisfied rather than reported — a full workspace build
(
turbo run build --filter='./packages/*' --filter='./packages/*/*', 71/71tasks successful) and both re-run at this same head:
packages/spec's regeneration step was run as AGENTS.md requires — afterrebuilding spec (the first run refused to compute against a
distolder thansrc, which is the same not-measured class),check:generatedreports all 15generated artifacts up to date, so nothing needed regenerating.
Repo-wide
pnpm lintis CI's run. The declared narrowing here iseslint --no-inline-configover the six linted changed files → 0 errors, 0warnings, with the three pieces of evidence a narrowing needs: (1) the
population comes from ESLint's own config, not a guess —
isPathIgnored+calculateConfigForFilereport the six sources LINTED and the changesetmarkdown IGNORED; (2) the file count is read from
--format json(6); (3) thisrepo "never enables type-aware linting (no
parserOptions.project, no typed@typescript-eslintrules) for ANY file" —eslint.config.mjs's own measuredstatement — so this diff cannot move the verdict on any file it does not touch.
Landing posture
Clause-② is
no, declared from what the change does: the key is alreadydeclared (PR #15143), this populates it, nothing is newly accepted or refused,
and no public surface widens. The dispatch nevertheless ran below
CONTRACT_REVIEW_TIERunder the quota-exhaustion fallback, and the enqueuegate's path leg fires on the
packages/spec/src/**edit that point 4requires — so this PR parks. It stays draft, is not flipped, and
auto-merge is not armed; it waits for an at-tier reviewer.
Out of scope, filed
#15303 —
content/docs/ui/actions.mdxstill tells authors the flow face is"declared but not yet populated", which this PR falsifies. It sits outside the
file surface this card enumerated, so it is filed rather than ridden along, and
it is sequenced to land after this one.
check:affected-docsandcheck:drift-commentare both green over this change, so no gate carries it.#15303 remains open.
🤖 Generated with Claude Code
https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Generated by Claude Code