Skip to content

runtime: carry the caller-scope record-load signal into a flow action's context — both doors (#15168) - #15304

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-15168-flow-action-record-load-signal
Sep 4, 2026
Merged

os-warren merged 3 commits into
mainfrom
claude/issue-15168-flow-action-record-load-signal

Conversation

@os-litant

Copy link
Copy Markdown
Contributor

Fixes #15168

The runtime half of #14244, whose contract half landed in PR #15143.
AutomationContext.recordLoadDenied?: true was declared and nothing
populated it on the flow face: dispatchFlowAction handed
automation.execute a context without it, so a runAs: 'system' flow that
guarded on the documented key was inert — never true, never wrong, and
indistinguishable from a flow whose caller could read the row. The stamped
record.id is present either way (new-record / record-less actions depend on
that 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 MCP run_action
bridge (action-execution.ts).

The shape

const result: any = await automation.execute(action.target, {
    record,
    ...actionRecordLoadSignal(subject),   // sibling of `record`, spread
    ...
});
  • A sibling of record, never a key on it. A flow node reading
    record.recordLoadDenied finds nothing; a pin asserts
    'recordLoadDenied' in ctx.record === false on the denied path.
  • Absent, never false. The producer already returns exactly
    { recordLoadDenied?: true }, so the spread is what makes the key absent
    when nothing was refused — matching the contract's true | undefined and the
    handler face's recordLoadDenied === true convention.
  • Context-only. This card populates the key. Whether the automation engine
    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, not record

dispatchFlowAction's wiring changes from record, a plain string-keyed record, to
subject: ActionSubjectRecordLoad, and the door derives record and the
signal from that one outcome. The card allowed either this or passing the
already-computed signal object; this is the stronger of the two:

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 dispatchFlowAction itself.

Point 4 — the retired sentence

packages/spec/src/contracts/automation-service.ts carried "Declared, NOT YET
POPULATED on the flow face…". That is now the populated statement, and
automation-context-record-load-denied.pin.test.ts — the pin that named the
sentence so its author would be told what to retire — asserts both
directions: the populated wording is present and NOT YET POPULATED is 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/spec edit pre-authorised on the card (plus its pin).
Nothing else under packages/spec/** is touched.

Tests — all runs at 5ebf3cb3, this PR's head

Seven new pins in packages/runtime/src/action-record-load-denied.test.ts,
sharing that file's existing row-scoped engine double (crm_case:case_1 is
visible to its owner and to nobody else) and the REAL callData, so the
refused/absent collapse is not mocked away:

pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 --reporter=verbose \
  src/action-record-load-denied.test.ts
  Test Files  1 passed (1)       Tests  20 passed (20)     # 13 pre-existing + 7 new
pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 \
  src/action-record-load-denied.test.ts src/actions-flow-dispatch-status.test.ts \
  src/action-object-less-key-agreement.test.ts
  Test Files  3 passed (3)       Tests  45 passed (45)

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)

pnpm --filter @objectstack/runtime --filter @objectstack/spec run typecheck   → exit 0
  (both packages' `check:test-typecheck` included, so the new test files compile)

Two ablations, each proved on disk and restored from HEAD

The marker ...actionRecordLoadSignal(subject), occurs twice in
action-execution.ts (the flow door and the script-body door), so each
mutation is proved by a count delta plus the injected marker, not by a
presence test, and by a blob-hash difference against the HEAD blob. Every
restore is proved by git hash-object equalling the HEAD blob and by an
empty git diff HEAD for that path. Direction was predicted before each run
and both matched:

ablation mutation predicted measured
A delete the flow door's spread (the pre-#15168 state) the 2 denied-door cases + convergence red; the absent cases stay green Tests 3 failed / 17 passed — exactly those three
B recordLoadDenied: subject.recordLoadDenied (i.e. false when readable — the likeliest wrong implementation) the 2 owner cases + 2 record-less cases + convergence red; the denied cases stay green Tests 5 failed / 15 passed — exactly those five
control restored tree green Tests 20 passed (20)

The asymmetry is the point: neither the absent-not-false pins nor the denied
pins 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.js relatively, resolving to src, with no dist on the
path (KNOWN_UNALIASED_TEST_IMPORTS untouched; check:test-source-alias
green).

Gates

Derived on a clean tree at this head, never hand-written:

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands
  → 63 commands, 7 paths vs merge base 4dd5041bd

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 package
    has no dist/… ⛔ This is NOT a pass: nothing was measured."
  • check:type-check-debt: --re-measure refuses 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/71
tasks successful
) and both re-run at this same head:

pnpm check:dual-build-cjs-loads   → exit 0
  ✓ 102 published require entry point(s) across 66 package(s) load;
    610 emitted CommonJS file(s) parse; floors 90/58/520/1 held
pnpm check:type-check-debt        → exit 0
  OK — 15 ledger entr(ies) re-measured, 204 raw tsc error(s), none above
       its recorded number; "surplus: none — every entry sits exactly at
       its measurement, so any new error is red"

packages/spec's regeneration step was run as AGENTS.md requires — after
rebuilding spec (the first run refused to compute against a dist older than
src, which is the same not-measured class), check:generated reports all 15
generated artifacts up to date
, so nothing needed regenerating.

Repo-wide pnpm lint is CI's run. The declared narrowing here is
eslint --no-inline-config over the six linted changed files → 0 errors, 0
warnings
, with the three pieces of evidence a narrowing needs: (1) the
population comes from ESLint's own config, not a guess — isPathIgnored +
calculateConfigForFile report the six sources LINTED and the changeset
markdown IGNORED; (2) the file count is read from --format json (6); (3) this
repo "never enables type-aware linting (no parserOptions.project, no typed
@typescript-eslint rules) for ANY file" — eslint.config.mjs's own measured
statement — 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 already
declared (PR #15143), this populates it, nothing is newly accepted or refused,
and no public surface widens. The dispatch nevertheless ran below
CONTRACT_REVIEW_TIER under the quota-exhaustion fallback, and the enqueue
gate's path leg fires on the packages/spec/src/** edit that point 4
requires — 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.mdx still 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-docs and
check:drift-comment are 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

…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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

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

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

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 134 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 5b09356b75a06615e705b432bc1a9ddc70e37e4a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 468a8c54261e281a2866be5d44629b1fa9bc8995 — the merge of head 5ebf3cb31cf2235a4cc131f01ffbb1ac1796f607 into base 5b09356b75a06615e705b432bc1a9ddc70e37e4a, 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 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

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

Copy link
Copy Markdown
Collaborator

Landing — contract review PASS at CONTRACT_REVIEW_TIER (card comment 5539900755). Ready, squash auto-merge armed.

Director seat, session session_01LsEjuNMPitCHwEfYftZ1um (os-warren), 11:39Z.


Generated by Claude Code

Merged via the queue into main with commit 2c753fe Sep 4, 2026
43 checks passed
@os-warren
os-warren deleted the claude/issue-15168-flow-action-record-load-signal branch September 4, 2026 12:06
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

3 participants