fix(runtime): route the flow param seeder through the single object-less action-key predicate - #15018

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates
Sep 3, 2026
Merged

fix(runtime): route the flow param seeder through the single object-less action-key predicate#15018
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14864

File face — declared, not discovered

This PR touches 5 paths, all in packages/runtime and .changeset:

  • packages/runtime/src/action-execution.ts
  • packages/runtime/src/action-owner-key-single-source.test.ts
  • packages/runtime/src/action-object-less-key-agreement.test.ts (new)
  • packages/runtime/src/action-params-enforcement.test.ts (new)
  • .changeset/object-less-action-key-one-predicate.md (new)

Card #13906 (p1 · security · computeExecCtx seams) was flagged as a possible
collision. It is not one: its committed face is packages/rest/src/rest-api-plugin.ts,
packages/rest/src/rest-server.ts, two packages/rest tests, a packages/qa test and an
ADR — zero files under packages/runtime. Read from its worktree's own diff against
the merge base, not inferred. No file is shared.

packages/spec was read, never edited. packages/rest/src/error-response.ts and
packages/cli/src/commands/generate.ts were not touched.

The headline claim on the card is FALSE, and the ablation is how I know

The card and its triage both say "neither seedFlowActionParams nor enforceActionParams
is named by any test"
, and both correctly refused to let anyone build on it. Ablated
repo-wide against packages/runtime (baseline 217 files / 3143 tests, all green), each
mutation proven on disk by blob hash and anchor counts, each restore proven by blob-hash
equality against the HEAD blob, under a trap … EXIT INT TERM on absolute paths:

ablationresult
seedFlowActionParams gutted to return { ...params }5 tests RED in http-dispatcher.actions-type-dispatch.test.ts
enforceActionParams replaced with an unconditional return null3143 passed, 0 failed

So the two functions are in opposite states, and the card's single sentence was wrong
about one of them and right about the other:

  • seedFlowActionParams was pinned all along — indirectly, through the REST route,
    in a file that never names it. Exactly the "pinned somewhere I did not look" case the
    method warning predicts. But every case in that file routes at a real object
    (/crm_lead/...), so its object-LESS leg — where the two predicates actually disagree —
    was the unpinned part. That is a sharper finding than "unpinned", and it is why the
    divergence survived: the ladder was pinned, the leg was not.
  • enforceActionParams is genuinely unpinned. The ADR-0104 D2 gate could stop
    existing entirely and nothing in the repo would notice. Its validator
    (validateActionParams) is thoroughly pinned in @objectstack/spec; the runtime gate
    wrapped around it was not, and a green validator says nothing about whether anything
    still calls it. Its only other mention outside the source is a manually-run
    platform-checklist clause. Confirmed by a repo-wide search for its one observable string
    Invalid action params — zero automated assertions, with a positive control that fired.

Is '*' dead, or merely unused today? — merely unused

This is the measurement the card said to make first, and it decides the direction.

  • Nothing first-party registers under '*', so the divergence is not producing wrong
    results in this repo today.
  • But actionHandlerObjectKeysdeliberately probes '*' last, with a docblock saying
    why: "kept last so a handler that user code registered directly against the wildcard
    still resolves."
    registerAction(objectName, actionName, handler) is public engine
    surface — user code can and does pass its own key.
  • And the route reaches it. objectName on this path is a raw URL path segment
    (domains/actions.ts), unvalidated, so POST /actions/*/theAction/theId resolves a
    global flow action through ownsRoute and lands in the divergence with objectName
    equal to '*'.

'*' is a live, deliberately-honoured legacy read path that happens to have no
first-party writer
. Retiring it is a compatibility decision about someone else's package,
with a retirement question attached (#3913). That is not a tidy-up this p3 is entitled to
make.

Direction taken: widen, and the divergence is narrower than the card says

seedFlowActionParams now asks isObjectLessActionKey(objectName) — the same predicate
dispatchFlowAction asks three lines away before deciding whether to send an object at
all. One predicate, one answer.

⚠️Correction to the card's anchor. The card describes the sibling as treating "'*'
and''" as object-less, implying two divergent inputs. Measured against origin/main,
it is exactly one. The old guard was objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY,
and its objectName && truthiness leg already covered '' (and undefined):

routed keyisObjectLessActionKeyold guard seeds an aliasagree?
'crm_lead'falseyes
'global'trueno
''trueno
'*'trueyes — seeds '*Id'

The observable defect is a params bag carrying a nonsense key spelled '*Id' beside
recordId, for a route the envelope on the previous line already called object-less.

The pin, red-first, naming which arm fired

action-object-less-key-agreement.test.ts run against unmodifiedorigin/main source:

❯ src/action-object-less-key-agreement.test.ts (6 tests | 2 failed)
× seeds no object alias for the object-less key '"*"'
× every object-less spelling lands the SAME bag as the canonical key
AssertionError: seedFlowActionParams seeded the alias key "*Id" for the object-less
route "*". … expected [ 'recordId', '*Id' ] to not include '*Id'
AssertionError: routing at "*" produced a different params bag:
expected { recordId: 'row_1', '*Id': 'row_1' } to deeply equal { recordId: 'row_1' }

The arm that fired is the negative arm, not a control. Specifically:

  • the anti-vacuity control (an object-BOUND route still seeds its alias key) passed
    the harness runs and the seeding path is live, so the negatives are not passing vacuously;
  • the 'global' and '' rows passed — which is the table above, measured rather than
    argued: only '*' diverged;
  • the '*' row failed with the message the pin was written to emit.

For the second pin, enforceActionParams, the red-first proof is the ablation itself:
re-running the A2 mutation with the new pin present turns 3 of its 5 tests red where
the whole 3143-test suite had stayed green — and the two that stay green are the
anti-vacuity control and the param-less pass-through, which that mutation genuinely does
not affect. Right arms, both directions.

Verification

All at final HEAD ae512c7e73, heavy runs through scripts/pm/os-verify-lock.sh, exits
captured before any pipe.

  • pnpm --filter @objectstack/runtime exec vitest run — 219 files / 3154 passed
    (baseline on origin/main was 217 / 3143; the delta is exactly the two new pin files).
  • pnpm --filter @objectstack/runtime typecheck (run by name, chains
    check:test-typecheck) — gate's own verdict:
    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) held in test-typecheck-debt.json.
    test-typecheck-debt.json is untouched — the new test files compile clean; nothing
    was added to the shrink-only ledger, no any, no ts-expect-error, no weakened assertion.
  • 31 gate families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
    at this HEAD (not from a hand-written diff): 28 green, 3 NOT MEASURED — each
    an exit 3, a gate's own PREREQUISITE NOT MET, which is not a red:
    • check:test-completeness — grades a saved turbo run test log; CI tees one, there is
      none locally, and the gate's own text says to record it as NOT MEASURED.
    • check:dual-build-cjs-loads — needs a whole-repo pnpm build first; that is CI's run.
    • scripts/pm/check-half-states.mjs — repo-scoped GitHub reads are 403 on this session
      (GET /rate_limit answers 200 with 15000 left; GET /repos/... answers 403). Its
      self-test half, pnpm check:pm-half-states, passed with 2062 cases.

pnpm lint — narrowed, and the narrowing is a measurement, declared as such. The
repo-wide eslint . --no-inline-config is CI's run. Locally I linted the 4 source paths and
prove the narrowing excluded nothing:

  1. the universe is read from eslint's own config, not guessed — this repo runs one
    eslint.config.mjs, and its own header states it "never enables type-aware linting (no
    parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not"

    (eslint.config.mjs:325-335);
  2. the file count is read from --format json: 4 files linted, 0 errors, 0 warnings;
  3. invariance follows from (1): with no type-aware program anywhere, no edit in this diff
    can move the verdict of a file it does not contain.

Scope

The enforceActionParams pin is a second file beyond the strict divergence fix, named here
because a coverage addition should never arrive unnamed. It is in scope: the card names that
function as half of its primary deliverable, the ablation proved it genuinely unpinned, no
other claim holds the file, and it adds no new gate surface. It changes no behaviour — it
pins the behaviour that is already there.

Changeset: patch on @objectstack/runtime — a behaviour change in a released package,
which is the fork dispatch-gates.mjs prints. Not skip-changeset: this publishes from a
released package, so that label would be wrong here.

Kept out of scope, deliberately: retiring '*', which needs the compatibility decision
above; and packages/spec, read but never edited.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…104 D2 param gate
Both functions were ablated repo-wide first. `seedFlowActionParams` turned out
to be pinned already — indirectly, through the REST route — but only on its
object-BOUND leg; `enforceActionParams` had no pin at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ess predicate
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 — 24 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 fddfc8db062d61ca68ba482531f5368326109554packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6a3d903a77f11d69f7779211eacbb68884e98254 — the merge of head ae512c7e73df6f32d45393355e0fea50048fbb7e into base fddfc8db062d61ca68ba482531f5368326109554, 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 6a3d903a77f11d69f7779211eacbb68884e98254 && git checkout 6a3d903a77f11d69f7779211eacbb68884e98254
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fddfc8db062d61ca68ba482531f5368326109554 ae512c7e73df6f32d45393355e0fea50048fbb7e && git checkout -B drift-repro fddfc8db062d61ca68ba482531f5368326109554 && git merge --no-ff ae512c7e73df6f32d45393355e0fea50048fbb7e
node scripts/docs-audit/affected-docs.mjs --json fddfc8db062d61ca68ba482531f5368326109554

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(runtime): route the flow param seeder through the single object-less action-key predicate - #15018

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates
Sep 3, 2026
Merged

fix(runtime): route the flow param seeder through the single object-less action-key predicate#15018
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14864

File face — declared, not discovered

This PR touches 5 paths, all in packages/runtime and .changeset:

  • packages/runtime/src/action-execution.ts
  • packages/runtime/src/action-owner-key-single-source.test.ts
  • packages/runtime/src/action-object-less-key-agreement.test.ts (new)
  • packages/runtime/src/action-params-enforcement.test.ts (new)
  • .changeset/object-less-action-key-one-predicate.md (new)

Card #13906 (p1 · security · computeExecCtx seams) was flagged as a possible
collision. It is not one: its committed face is packages/rest/src/rest-api-plugin.ts,
packages/rest/src/rest-server.ts, two packages/rest tests, a packages/qa test and an
ADR — zero files under packages/runtime. Read from its worktree's own diff against
the merge base, not inferred. No file is shared.

packages/spec was read, never edited. packages/rest/src/error-response.ts and
packages/cli/src/commands/generate.ts were not touched.

The headline claim on the card is FALSE, and the ablation is how I know

The card and its triage both say "neither seedFlowActionParams nor enforceActionParams
is named by any test"
, and both correctly refused to let anyone build on it. Ablated
repo-wide against packages/runtime (baseline 217 files / 3143 tests, all green), each
mutation proven on disk by blob hash and anchor counts, each restore proven by blob-hash
equality against the HEAD blob, under a trap … EXIT INT TERM on absolute paths:

ablationresult
seedFlowActionParams gutted to return { ...params }5 tests RED in http-dispatcher.actions-type-dispatch.test.ts
enforceActionParams replaced with an unconditional return null3143 passed, 0 failed

So the two functions are in opposite states, and the card's single sentence was wrong
about one of them and right about the other:

  • seedFlowActionParams was pinned all along — indirectly, through the REST route,
    in a file that never names it. Exactly the "pinned somewhere I did not look" case the
    method warning predicts. But every case in that file routes at a real object
    (/crm_lead/...), so its object-LESS leg — where the two predicates actually disagree —
    was the unpinned part. That is a sharper finding than "unpinned", and it is why the
    divergence survived: the ladder was pinned, the leg was not.
  • enforceActionParams is genuinely unpinned. The ADR-0104 D2 gate could stop
    existing entirely and nothing in the repo would notice. Its validator
    (validateActionParams) is thoroughly pinned in @objectstack/spec; the runtime gate
    wrapped around it was not, and a green validator says nothing about whether anything
    still calls it. Its only other mention outside the source is a manually-run
    platform-checklist clause. Confirmed by a repo-wide search for its one observable string
    Invalid action params — zero automated assertions, with a positive control that fired.

Is '*' dead, or merely unused today? — merely unused

This is the measurement the card said to make first, and it decides the direction.

  • Nothing first-party registers under '*', so the divergence is not producing wrong
    results in this repo today.
  • But actionHandlerObjectKeysdeliberately probes '*' last, with a docblock saying
    why: "kept last so a handler that user code registered directly against the wildcard
    still resolves."
    registerAction(objectName, actionName, handler) is public engine
    surface — user code can and does pass its own key.
  • And the route reaches it. objectName on this path is a raw URL path segment
    (domains/actions.ts), unvalidated, so POST /actions/*/theAction/theId resolves a
    global flow action through ownsRoute and lands in the divergence with objectName
    equal to '*'.

'*' is a live, deliberately-honoured legacy read path that happens to have no
first-party writer
. Retiring it is a compatibility decision about someone else's package,
with a retirement question attached (#3913). That is not a tidy-up this p3 is entitled to
make.

Direction taken: widen, and the divergence is narrower than the card says

seedFlowActionParams now asks isObjectLessActionKey(objectName) — the same predicate
dispatchFlowAction asks three lines away before deciding whether to send an object at
all. One predicate, one answer.

⚠️Correction to the card's anchor. The card describes the sibling as treating "'*'
and''" as object-less, implying two divergent inputs. Measured against origin/main,
it is exactly one. The old guard was objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY,
and its objectName && truthiness leg already covered '' (and undefined):

routed keyisObjectLessActionKeyold guard seeds an aliasagree?
'crm_lead'falseyes
'global'trueno
''trueno
'*'trueyes — seeds '*Id'

The observable defect is a params bag carrying a nonsense key spelled '*Id' beside
recordId, for a route the envelope on the previous line already called object-less.

The pin, red-first, naming which arm fired

action-object-less-key-agreement.test.ts run against unmodifiedorigin/main source:

❯ src/action-object-less-key-agreement.test.ts (6 tests | 2 failed)
× seeds no object alias for the object-less key '"*"'
× every object-less spelling lands the SAME bag as the canonical key
AssertionError: seedFlowActionParams seeded the alias key "*Id" for the object-less
route "*". … expected [ 'recordId', '*Id' ] to not include '*Id'
AssertionError: routing at "*" produced a different params bag:
expected { recordId: 'row_1', '*Id': 'row_1' } to deeply equal { recordId: 'row_1' }

The arm that fired is the negative arm, not a control. Specifically:

  • the anti-vacuity control (an object-BOUND route still seeds its alias key) passed
    the harness runs and the seeding path is live, so the negatives are not passing vacuously;
  • the 'global' and '' rows passed — which is the table above, measured rather than
    argued: only '*' diverged;
  • the '*' row failed with the message the pin was written to emit.

For the second pin, enforceActionParams, the red-first proof is the ablation itself:
re-running the A2 mutation with the new pin present turns 3 of its 5 tests red where
the whole 3143-test suite had stayed green — and the two that stay green are the
anti-vacuity control and the param-less pass-through, which that mutation genuinely does
not affect. Right arms, both directions.

Verification

All at final HEAD ae512c7e73, heavy runs through scripts/pm/os-verify-lock.sh, exits
captured before any pipe.

  • pnpm --filter @objectstack/runtime exec vitest run — 219 files / 3154 passed
    (baseline on origin/main was 217 / 3143; the delta is exactly the two new pin files).
  • pnpm --filter @objectstack/runtime typecheck (run by name, chains
    check:test-typecheck) — gate's own verdict:
    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) held in test-typecheck-debt.json.
    test-typecheck-debt.json is untouched — the new test files compile clean; nothing
    was added to the shrink-only ledger, no any, no ts-expect-error, no weakened assertion.
  • 31 gate families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
    at this HEAD (not from a hand-written diff): 28 green, 3 NOT MEASURED — each
    an exit 3, a gate's own PREREQUISITE NOT MET, which is not a red:
    • check:test-completeness — grades a saved turbo run test log; CI tees one, there is
      none locally, and the gate's own text says to record it as NOT MEASURED.
    • check:dual-build-cjs-loads — needs a whole-repo pnpm build first; that is CI's run.
    • scripts/pm/check-half-states.mjs — repo-scoped GitHub reads are 403 on this session
      (GET /rate_limit answers 200 with 15000 left; GET /repos/... answers 403). Its
      self-test half, pnpm check:pm-half-states, passed with 2062 cases.

pnpm lint — narrowed, and the narrowing is a measurement, declared as such. The
repo-wide eslint . --no-inline-config is CI's run. Locally I linted the 4 source paths and
prove the narrowing excluded nothing:

  1. the universe is read from eslint's own config, not guessed — this repo runs one
    eslint.config.mjs, and its own header states it "never enables type-aware linting (no
    parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not"

    (eslint.config.mjs:325-335);
  2. the file count is read from --format json: 4 files linted, 0 errors, 0 warnings;
  3. invariance follows from (1): with no type-aware program anywhere, no edit in this diff
    can move the verdict of a file it does not contain.

Scope

The enforceActionParams pin is a second file beyond the strict divergence fix, named here
because a coverage addition should never arrive unnamed. It is in scope: the card names that
function as half of its primary deliverable, the ablation proved it genuinely unpinned, no
other claim holds the file, and it adds no new gate surface. It changes no behaviour — it
pins the behaviour that is already there.

Changeset: patch on @objectstack/runtime — a behaviour change in a released package,
which is the fork dispatch-gates.mjs prints. Not skip-changeset: this publishes from a
released package, so that label would be wrong here.

Kept out of scope, deliberately: retiring '*', which needs the compatibility decision
above; and packages/spec, read but never edited.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…104 D2 param gate
Both functions were ablated repo-wide first. `seedFlowActionParams` turned out
to be pinned already — indirectly, through the REST route — but only on its
object-BOUND leg; `enforceActionParams` had no pin at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ess predicate
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 — 24 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 fddfc8db062d61ca68ba482531f5368326109554packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6a3d903a77f11d69f7779211eacbb68884e98254 — the merge of head ae512c7e73df6f32d45393355e0fea50048fbb7e into base fddfc8db062d61ca68ba482531f5368326109554, 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 6a3d903a77f11d69f7779211eacbb68884e98254 && git checkout 6a3d903a77f11d69f7779211eacbb68884e98254
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fddfc8db062d61ca68ba482531f5368326109554 ae512c7e73df6f32d45393355e0fea50048fbb7e && git checkout -B drift-repro fddfc8db062d61ca68ba482531f5368326109554 && git merge --no-ff ae512c7e73df6f32d45393355e0fea50048fbb7e
node scripts/docs-audit/affected-docs.mjs --json fddfc8db062d61ca68ba482531f5368326109554

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): route the flow param seeder through the single object-less action-key predicate - #15018

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates
Sep 3, 2026
Merged

fix(runtime): route the flow param seeder through the single object-less action-key predicate#15018
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14864

File face — declared, not discovered

This PR touches 5 paths, all in packages/runtime and .changeset:

  • packages/runtime/src/action-execution.ts
  • packages/runtime/src/action-owner-key-single-source.test.ts
  • packages/runtime/src/action-object-less-key-agreement.test.ts (new)
  • packages/runtime/src/action-params-enforcement.test.ts (new)
  • .changeset/object-less-action-key-one-predicate.md (new)

Card #13906 (p1 · security · computeExecCtx seams) was flagged as a possible
collision. It is not one: its committed face is packages/rest/src/rest-api-plugin.ts,
packages/rest/src/rest-server.ts, two packages/rest tests, a packages/qa test and an
ADR — zero files under packages/runtime. Read from its worktree's own diff against
the merge base, not inferred. No file is shared.

packages/spec was read, never edited. packages/rest/src/error-response.ts and
packages/cli/src/commands/generate.ts were not touched.

The headline claim on the card is FALSE, and the ablation is how I know

The card and its triage both say "neither seedFlowActionParams nor enforceActionParams
is named by any test"
, and both correctly refused to let anyone build on it. Ablated
repo-wide against packages/runtime (baseline 217 files / 3143 tests, all green), each
mutation proven on disk by blob hash and anchor counts, each restore proven by blob-hash
equality against the HEAD blob, under a trap … EXIT INT TERM on absolute paths:

ablationresult
seedFlowActionParams gutted to return { ...params }5 tests RED in http-dispatcher.actions-type-dispatch.test.ts
enforceActionParams replaced with an unconditional return null3143 passed, 0 failed

So the two functions are in opposite states, and the card's single sentence was wrong
about one of them and right about the other:

  • seedFlowActionParams was pinned all along — indirectly, through the REST route,
    in a file that never names it. Exactly the "pinned somewhere I did not look" case the
    method warning predicts. But every case in that file routes at a real object
    (/crm_lead/...), so its object-LESS leg — where the two predicates actually disagree —
    was the unpinned part. That is a sharper finding than "unpinned", and it is why the
    divergence survived: the ladder was pinned, the leg was not.
  • enforceActionParams is genuinely unpinned. The ADR-0104 D2 gate could stop
    existing entirely and nothing in the repo would notice. Its validator
    (validateActionParams) is thoroughly pinned in @objectstack/spec; the runtime gate
    wrapped around it was not, and a green validator says nothing about whether anything
    still calls it. Its only other mention outside the source is a manually-run
    platform-checklist clause. Confirmed by a repo-wide search for its one observable string
    Invalid action params — zero automated assertions, with a positive control that fired.

Is '*' dead, or merely unused today? — merely unused

This is the measurement the card said to make first, and it decides the direction.

  • Nothing first-party registers under '*', so the divergence is not producing wrong
    results in this repo today.
  • But actionHandlerObjectKeysdeliberately probes '*' last, with a docblock saying
    why: "kept last so a handler that user code registered directly against the wildcard
    still resolves."
    registerAction(objectName, actionName, handler) is public engine
    surface — user code can and does pass its own key.
  • And the route reaches it. objectName on this path is a raw URL path segment
    (domains/actions.ts), unvalidated, so POST /actions/*/theAction/theId resolves a
    global flow action through ownsRoute and lands in the divergence with objectName
    equal to '*'.

'*' is a live, deliberately-honoured legacy read path that happens to have no
first-party writer
. Retiring it is a compatibility decision about someone else's package,
with a retirement question attached (#3913). That is not a tidy-up this p3 is entitled to
make.

Direction taken: widen, and the divergence is narrower than the card says

seedFlowActionParams now asks isObjectLessActionKey(objectName) — the same predicate
dispatchFlowAction asks three lines away before deciding whether to send an object at
all. One predicate, one answer.

⚠️Correction to the card's anchor. The card describes the sibling as treating "'*'
and''" as object-less, implying two divergent inputs. Measured against origin/main,
it is exactly one. The old guard was objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY,
and its objectName && truthiness leg already covered '' (and undefined):

routed keyisObjectLessActionKeyold guard seeds an aliasagree?
'crm_lead'falseyes
'global'trueno
''trueno
'*'trueyes — seeds '*Id'

The observable defect is a params bag carrying a nonsense key spelled '*Id' beside
recordId, for a route the envelope on the previous line already called object-less.

The pin, red-first, naming which arm fired

action-object-less-key-agreement.test.ts run against unmodifiedorigin/main source:

❯ src/action-object-less-key-agreement.test.ts (6 tests | 2 failed)
× seeds no object alias for the object-less key '"*"'
× every object-less spelling lands the SAME bag as the canonical key
AssertionError: seedFlowActionParams seeded the alias key "*Id" for the object-less
route "*". … expected [ 'recordId', '*Id' ] to not include '*Id'
AssertionError: routing at "*" produced a different params bag:
expected { recordId: 'row_1', '*Id': 'row_1' } to deeply equal { recordId: 'row_1' }

The arm that fired is the negative arm, not a control. Specifically:

  • the anti-vacuity control (an object-BOUND route still seeds its alias key) passed
    the harness runs and the seeding path is live, so the negatives are not passing vacuously;
  • the 'global' and '' rows passed — which is the table above, measured rather than
    argued: only '*' diverged;
  • the '*' row failed with the message the pin was written to emit.

For the second pin, enforceActionParams, the red-first proof is the ablation itself:
re-running the A2 mutation with the new pin present turns 3 of its 5 tests red where
the whole 3143-test suite had stayed green — and the two that stay green are the
anti-vacuity control and the param-less pass-through, which that mutation genuinely does
not affect. Right arms, both directions.

Verification

All at final HEAD ae512c7e73, heavy runs through scripts/pm/os-verify-lock.sh, exits
captured before any pipe.

  • pnpm --filter @objectstack/runtime exec vitest run — 219 files / 3154 passed
    (baseline on origin/main was 217 / 3143; the delta is exactly the two new pin files).
  • pnpm --filter @objectstack/runtime typecheck (run by name, chains
    check:test-typecheck) — gate's own verdict:
    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) held in test-typecheck-debt.json.
    test-typecheck-debt.json is untouched — the new test files compile clean; nothing
    was added to the shrink-only ledger, no any, no ts-expect-error, no weakened assertion.
  • 31 gate families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
    at this HEAD (not from a hand-written diff): 28 green, 3 NOT MEASURED — each
    an exit 3, a gate's own PREREQUISITE NOT MET, which is not a red:
    • check:test-completeness — grades a saved turbo run test log; CI tees one, there is
      none locally, and the gate's own text says to record it as NOT MEASURED.
    • check:dual-build-cjs-loads — needs a whole-repo pnpm build first; that is CI's run.
    • scripts/pm/check-half-states.mjs — repo-scoped GitHub reads are 403 on this session
      (GET /rate_limit answers 200 with 15000 left; GET /repos/... answers 403). Its
      self-test half, pnpm check:pm-half-states, passed with 2062 cases.

pnpm lint — narrowed, and the narrowing is a measurement, declared as such. The
repo-wide eslint . --no-inline-config is CI's run. Locally I linted the 4 source paths and
prove the narrowing excluded nothing:

  1. the universe is read from eslint's own config, not guessed — this repo runs one
    eslint.config.mjs, and its own header states it "never enables type-aware linting (no
    parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not"

    (eslint.config.mjs:325-335);
  2. the file count is read from --format json: 4 files linted, 0 errors, 0 warnings;
  3. invariance follows from (1): with no type-aware program anywhere, no edit in this diff
    can move the verdict of a file it does not contain.

Scope

The enforceActionParams pin is a second file beyond the strict divergence fix, named here
because a coverage addition should never arrive unnamed. It is in scope: the card names that
function as half of its primary deliverable, the ablation proved it genuinely unpinned, no
other claim holds the file, and it adds no new gate surface. It changes no behaviour — it
pins the behaviour that is already there.

Changeset: patch on @objectstack/runtime — a behaviour change in a released package,
which is the fork dispatch-gates.mjs prints. Not skip-changeset: this publishes from a
released package, so that label would be wrong here.

Kept out of scope, deliberately: retiring '*', which needs the compatibility decision
above; and packages/spec, read but never edited.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…104 D2 param gate
Both functions were ablated repo-wide first. `seedFlowActionParams` turned out
to be pinned already — indirectly, through the REST route — but only on its
object-BOUND leg; `enforceActionParams` had no pin at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ess predicate
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 — 24 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 fddfc8db062d61ca68ba482531f5368326109554packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6a3d903a77f11d69f7779211eacbb68884e98254 — the merge of head ae512c7e73df6f32d45393355e0fea50048fbb7e into base fddfc8db062d61ca68ba482531f5368326109554, 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 6a3d903a77f11d69f7779211eacbb68884e98254 && git checkout 6a3d903a77f11d69f7779211eacbb68884e98254
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fddfc8db062d61ca68ba482531f5368326109554 ae512c7e73df6f32d45393355e0fea50048fbb7e && git checkout -B drift-repro fddfc8db062d61ca68ba482531f5368326109554 && git merge --no-ff ae512c7e73df6f32d45393355e0fea50048fbb7e
node scripts/docs-audit/affected-docs.mjs --json fddfc8db062d61ca68ba482531f5368326109554

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): route the flow param seeder through the single object-less action-key predicate - #15018

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates
Sep 3, 2026
Merged

fix(runtime): route the flow param seeder through the single object-less action-key predicate#15018
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14864

File face — declared, not discovered

This PR touches 5 paths, all in packages/runtime and .changeset:

  • packages/runtime/src/action-execution.ts
  • packages/runtime/src/action-owner-key-single-source.test.ts
  • packages/runtime/src/action-object-less-key-agreement.test.ts (new)
  • packages/runtime/src/action-params-enforcement.test.ts (new)
  • .changeset/object-less-action-key-one-predicate.md (new)

Card #13906 (p1 · security · computeExecCtx seams) was flagged as a possible
collision. It is not one: its committed face is packages/rest/src/rest-api-plugin.ts,
packages/rest/src/rest-server.ts, two packages/rest tests, a packages/qa test and an
ADR — zero files under packages/runtime. Read from its worktree's own diff against
the merge base, not inferred. No file is shared.

packages/spec was read, never edited. packages/rest/src/error-response.ts and
packages/cli/src/commands/generate.ts were not touched.

The headline claim on the card is FALSE, and the ablation is how I know

The card and its triage both say "neither seedFlowActionParams nor enforceActionParams
is named by any test"
, and both correctly refused to let anyone build on it. Ablated
repo-wide against packages/runtime (baseline 217 files / 3143 tests, all green), each
mutation proven on disk by blob hash and anchor counts, each restore proven by blob-hash
equality against the HEAD blob, under a trap … EXIT INT TERM on absolute paths:

ablationresult
seedFlowActionParams gutted to return { ...params }5 tests RED in http-dispatcher.actions-type-dispatch.test.ts
enforceActionParams replaced with an unconditional return null3143 passed, 0 failed

So the two functions are in opposite states, and the card's single sentence was wrong
about one of them and right about the other:

  • seedFlowActionParams was pinned all along — indirectly, through the REST route,
    in a file that never names it. Exactly the "pinned somewhere I did not look" case the
    method warning predicts. But every case in that file routes at a real object
    (/crm_lead/...), so its object-LESS leg — where the two predicates actually disagree —
    was the unpinned part. That is a sharper finding than "unpinned", and it is why the
    divergence survived: the ladder was pinned, the leg was not.
  • enforceActionParams is genuinely unpinned. The ADR-0104 D2 gate could stop
    existing entirely and nothing in the repo would notice. Its validator
    (validateActionParams) is thoroughly pinned in @objectstack/spec; the runtime gate
    wrapped around it was not, and a green validator says nothing about whether anything
    still calls it. Its only other mention outside the source is a manually-run
    platform-checklist clause. Confirmed by a repo-wide search for its one observable string
    Invalid action params — zero automated assertions, with a positive control that fired.

Is '*' dead, or merely unused today? — merely unused

This is the measurement the card said to make first, and it decides the direction.

  • Nothing first-party registers under '*', so the divergence is not producing wrong
    results in this repo today.
  • But actionHandlerObjectKeysdeliberately probes '*' last, with a docblock saying
    why: "kept last so a handler that user code registered directly against the wildcard
    still resolves."
    registerAction(objectName, actionName, handler) is public engine
    surface — user code can and does pass its own key.
  • And the route reaches it. objectName on this path is a raw URL path segment
    (domains/actions.ts), unvalidated, so POST /actions/*/theAction/theId resolves a
    global flow action through ownsRoute and lands in the divergence with objectName
    equal to '*'.

'*' is a live, deliberately-honoured legacy read path that happens to have no
first-party writer
. Retiring it is a compatibility decision about someone else's package,
with a retirement question attached (#3913). That is not a tidy-up this p3 is entitled to
make.

Direction taken: widen, and the divergence is narrower than the card says

seedFlowActionParams now asks isObjectLessActionKey(objectName) — the same predicate
dispatchFlowAction asks three lines away before deciding whether to send an object at
all. One predicate, one answer.

⚠️Correction to the card's anchor. The card describes the sibling as treating "'*'
and''" as object-less, implying two divergent inputs. Measured against origin/main,
it is exactly one. The old guard was objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY,
and its objectName && truthiness leg already covered '' (and undefined):

routed keyisObjectLessActionKeyold guard seeds an aliasagree?
'crm_lead'falseyes
'global'trueno
''trueno
'*'trueyes — seeds '*Id'

The observable defect is a params bag carrying a nonsense key spelled '*Id' beside
recordId, for a route the envelope on the previous line already called object-less.

The pin, red-first, naming which arm fired

action-object-less-key-agreement.test.ts run against unmodifiedorigin/main source:

❯ src/action-object-less-key-agreement.test.ts (6 tests | 2 failed)
× seeds no object alias for the object-less key '"*"'
× every object-less spelling lands the SAME bag as the canonical key
AssertionError: seedFlowActionParams seeded the alias key "*Id" for the object-less
route "*". … expected [ 'recordId', '*Id' ] to not include '*Id'
AssertionError: routing at "*" produced a different params bag:
expected { recordId: 'row_1', '*Id': 'row_1' } to deeply equal { recordId: 'row_1' }

The arm that fired is the negative arm, not a control. Specifically:

  • the anti-vacuity control (an object-BOUND route still seeds its alias key) passed
    the harness runs and the seeding path is live, so the negatives are not passing vacuously;
  • the 'global' and '' rows passed — which is the table above, measured rather than
    argued: only '*' diverged;
  • the '*' row failed with the message the pin was written to emit.

For the second pin, enforceActionParams, the red-first proof is the ablation itself:
re-running the A2 mutation with the new pin present turns 3 of its 5 tests red where
the whole 3143-test suite had stayed green — and the two that stay green are the
anti-vacuity control and the param-less pass-through, which that mutation genuinely does
not affect. Right arms, both directions.

Verification

All at final HEAD ae512c7e73, heavy runs through scripts/pm/os-verify-lock.sh, exits
captured before any pipe.

  • pnpm --filter @objectstack/runtime exec vitest run — 219 files / 3154 passed
    (baseline on origin/main was 217 / 3143; the delta is exactly the two new pin files).
  • pnpm --filter @objectstack/runtime typecheck (run by name, chains
    check:test-typecheck) — gate's own verdict:
    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) held in test-typecheck-debt.json.
    test-typecheck-debt.json is untouched — the new test files compile clean; nothing
    was added to the shrink-only ledger, no any, no ts-expect-error, no weakened assertion.
  • 31 gate families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
    at this HEAD (not from a hand-written diff): 28 green, 3 NOT MEASURED — each
    an exit 3, a gate's own PREREQUISITE NOT MET, which is not a red:
    • check:test-completeness — grades a saved turbo run test log; CI tees one, there is
      none locally, and the gate's own text says to record it as NOT MEASURED.
    • check:dual-build-cjs-loads — needs a whole-repo pnpm build first; that is CI's run.
    • scripts/pm/check-half-states.mjs — repo-scoped GitHub reads are 403 on this session
      (GET /rate_limit answers 200 with 15000 left; GET /repos/... answers 403). Its
      self-test half, pnpm check:pm-half-states, passed with 2062 cases.

pnpm lint — narrowed, and the narrowing is a measurement, declared as such. The
repo-wide eslint . --no-inline-config is CI's run. Locally I linted the 4 source paths and
prove the narrowing excluded nothing:

  1. the universe is read from eslint's own config, not guessed — this repo runs one
    eslint.config.mjs, and its own header states it "never enables type-aware linting (no
    parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not"

    (eslint.config.mjs:325-335);
  2. the file count is read from --format json: 4 files linted, 0 errors, 0 warnings;
  3. invariance follows from (1): with no type-aware program anywhere, no edit in this diff
    can move the verdict of a file it does not contain.

Scope

The enforceActionParams pin is a second file beyond the strict divergence fix, named here
because a coverage addition should never arrive unnamed. It is in scope: the card names that
function as half of its primary deliverable, the ablation proved it genuinely unpinned, no
other claim holds the file, and it adds no new gate surface. It changes no behaviour — it
pins the behaviour that is already there.

Changeset: patch on @objectstack/runtime — a behaviour change in a released package,
which is the fork dispatch-gates.mjs prints. Not skip-changeset: this publishes from a
released package, so that label would be wrong here.

Kept out of scope, deliberately: retiring '*', which needs the compatibility decision
above; and packages/spec, read but never edited.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…104 D2 param gate
Both functions were ablated repo-wide first. `seedFlowActionParams` turned out
to be pinned already — indirectly, through the REST route — but only on its
object-BOUND leg; `enforceActionParams` had no pin at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ess predicate
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 — 24 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 fddfc8db062d61ca68ba482531f5368326109554packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6a3d903a77f11d69f7779211eacbb68884e98254 — the merge of head ae512c7e73df6f32d45393355e0fea50048fbb7e into base fddfc8db062d61ca68ba482531f5368326109554, 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 6a3d903a77f11d69f7779211eacbb68884e98254 && git checkout 6a3d903a77f11d69f7779211eacbb68884e98254
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fddfc8db062d61ca68ba482531f5368326109554 ae512c7e73df6f32d45393355e0fea50048fbb7e && git checkout -B drift-repro fddfc8db062d61ca68ba482531f5368326109554 && git merge --no-ff ae512c7e73df6f32d45393355e0fea50048fbb7e
node scripts/docs-audit/affected-docs.mjs --json fddfc8db062d61ca68ba482531f5368326109554

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(runtime): route the flow param seeder through the single object-less action-key predicate - #15018

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates
Sep 3, 2026
Merged

fix(runtime): route the flow param seeder through the single object-less action-key predicate#15018
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14864

File face — declared, not discovered

This PR touches 5 paths, all in packages/runtime and .changeset:

  • packages/runtime/src/action-execution.ts
  • packages/runtime/src/action-owner-key-single-source.test.ts
  • packages/runtime/src/action-object-less-key-agreement.test.ts (new)
  • packages/runtime/src/action-params-enforcement.test.ts (new)
  • .changeset/object-less-action-key-one-predicate.md (new)

Card #13906 (p1 · security · computeExecCtx seams) was flagged as a possible
collision. It is not one: its committed face is packages/rest/src/rest-api-plugin.ts,
packages/rest/src/rest-server.ts, two packages/rest tests, a packages/qa test and an
ADR — zero files under packages/runtime. Read from its worktree's own diff against
the merge base, not inferred. No file is shared.

packages/spec was read, never edited. packages/rest/src/error-response.ts and
packages/cli/src/commands/generate.ts were not touched.

The headline claim on the card is FALSE, and the ablation is how I know

The card and its triage both say "neither seedFlowActionParams nor enforceActionParams
is named by any test"
, and both correctly refused to let anyone build on it. Ablated
repo-wide against packages/runtime (baseline 217 files / 3143 tests, all green), each
mutation proven on disk by blob hash and anchor counts, each restore proven by blob-hash
equality against the HEAD blob, under a trap … EXIT INT TERM on absolute paths:

ablationresult
seedFlowActionParams gutted to return { ...params }5 tests RED in http-dispatcher.actions-type-dispatch.test.ts
enforceActionParams replaced with an unconditional return null3143 passed, 0 failed

So the two functions are in opposite states, and the card's single sentence was wrong
about one of them and right about the other:

  • seedFlowActionParams was pinned all along — indirectly, through the REST route,
    in a file that never names it. Exactly the "pinned somewhere I did not look" case the
    method warning predicts. But every case in that file routes at a real object
    (/crm_lead/...), so its object-LESS leg — where the two predicates actually disagree —
    was the unpinned part. That is a sharper finding than "unpinned", and it is why the
    divergence survived: the ladder was pinned, the leg was not.
  • enforceActionParams is genuinely unpinned. The ADR-0104 D2 gate could stop
    existing entirely and nothing in the repo would notice. Its validator
    (validateActionParams) is thoroughly pinned in @objectstack/spec; the runtime gate
    wrapped around it was not, and a green validator says nothing about whether anything
    still calls it. Its only other mention outside the source is a manually-run
    platform-checklist clause. Confirmed by a repo-wide search for its one observable string
    Invalid action params — zero automated assertions, with a positive control that fired.

Is '*' dead, or merely unused today? — merely unused

This is the measurement the card said to make first, and it decides the direction.

  • Nothing first-party registers under '*', so the divergence is not producing wrong
    results in this repo today.
  • But actionHandlerObjectKeysdeliberately probes '*' last, with a docblock saying
    why: "kept last so a handler that user code registered directly against the wildcard
    still resolves."
    registerAction(objectName, actionName, handler) is public engine
    surface — user code can and does pass its own key.
  • And the route reaches it. objectName on this path is a raw URL path segment
    (domains/actions.ts), unvalidated, so POST /actions/*/theAction/theId resolves a
    global flow action through ownsRoute and lands in the divergence with objectName
    equal to '*'.

'*' is a live, deliberately-honoured legacy read path that happens to have no
first-party writer
. Retiring it is a compatibility decision about someone else's package,
with a retirement question attached (#3913). That is not a tidy-up this p3 is entitled to
make.

Direction taken: widen, and the divergence is narrower than the card says

seedFlowActionParams now asks isObjectLessActionKey(objectName) — the same predicate
dispatchFlowAction asks three lines away before deciding whether to send an object at
all. One predicate, one answer.

⚠️Correction to the card's anchor. The card describes the sibling as treating "'*'
and''" as object-less, implying two divergent inputs. Measured against origin/main,
it is exactly one. The old guard was objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY,
and its objectName && truthiness leg already covered '' (and undefined):

routed keyisObjectLessActionKeyold guard seeds an aliasagree?
'crm_lead'falseyes
'global'trueno
''trueno
'*'trueyes — seeds '*Id'

The observable defect is a params bag carrying a nonsense key spelled '*Id' beside
recordId, for a route the envelope on the previous line already called object-less.

The pin, red-first, naming which arm fired

action-object-less-key-agreement.test.ts run against unmodifiedorigin/main source:

❯ src/action-object-less-key-agreement.test.ts (6 tests | 2 failed)
× seeds no object alias for the object-less key '"*"'
× every object-less spelling lands the SAME bag as the canonical key
AssertionError: seedFlowActionParams seeded the alias key "*Id" for the object-less
route "*". … expected [ 'recordId', '*Id' ] to not include '*Id'
AssertionError: routing at "*" produced a different params bag:
expected { recordId: 'row_1', '*Id': 'row_1' } to deeply equal { recordId: 'row_1' }

The arm that fired is the negative arm, not a control. Specifically:

  • the anti-vacuity control (an object-BOUND route still seeds its alias key) passed
    the harness runs and the seeding path is live, so the negatives are not passing vacuously;
  • the 'global' and '' rows passed — which is the table above, measured rather than
    argued: only '*' diverged;
  • the '*' row failed with the message the pin was written to emit.

For the second pin, enforceActionParams, the red-first proof is the ablation itself:
re-running the A2 mutation with the new pin present turns 3 of its 5 tests red where
the whole 3143-test suite had stayed green — and the two that stay green are the
anti-vacuity control and the param-less pass-through, which that mutation genuinely does
not affect. Right arms, both directions.

Verification

All at final HEAD ae512c7e73, heavy runs through scripts/pm/os-verify-lock.sh, exits
captured before any pipe.

  • pnpm --filter @objectstack/runtime exec vitest run — 219 files / 3154 passed
    (baseline on origin/main was 217 / 3143; the delta is exactly the two new pin files).
  • pnpm --filter @objectstack/runtime typecheck (run by name, chains
    check:test-typecheck) — gate's own verdict:
    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) held in test-typecheck-debt.json.
    test-typecheck-debt.json is untouched — the new test files compile clean; nothing
    was added to the shrink-only ledger, no any, no ts-expect-error, no weakened assertion.
  • 31 gate families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
    at this HEAD (not from a hand-written diff): 28 green, 3 NOT MEASURED — each
    an exit 3, a gate's own PREREQUISITE NOT MET, which is not a red:
    • check:test-completeness — grades a saved turbo run test log; CI tees one, there is
      none locally, and the gate's own text says to record it as NOT MEASURED.
    • check:dual-build-cjs-loads — needs a whole-repo pnpm build first; that is CI's run.
    • scripts/pm/check-half-states.mjs — repo-scoped GitHub reads are 403 on this session
      (GET /rate_limit answers 200 with 15000 left; GET /repos/... answers 403). Its
      self-test half, pnpm check:pm-half-states, passed with 2062 cases.

pnpm lint — narrowed, and the narrowing is a measurement, declared as such. The
repo-wide eslint . --no-inline-config is CI's run. Locally I linted the 4 source paths and
prove the narrowing excluded nothing:

  1. the universe is read from eslint's own config, not guessed — this repo runs one
    eslint.config.mjs, and its own header states it "never enables type-aware linting (no
    parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not"

    (eslint.config.mjs:325-335);
  2. the file count is read from --format json: 4 files linted, 0 errors, 0 warnings;
  3. invariance follows from (1): with no type-aware program anywhere, no edit in this diff
    can move the verdict of a file it does not contain.

Scope

The enforceActionParams pin is a second file beyond the strict divergence fix, named here
because a coverage addition should never arrive unnamed. It is in scope: the card names that
function as half of its primary deliverable, the ablation proved it genuinely unpinned, no
other claim holds the file, and it adds no new gate surface. It changes no behaviour — it
pins the behaviour that is already there.

Changeset: patch on @objectstack/runtime — a behaviour change in a released package,
which is the fork dispatch-gates.mjs prints. Not skip-changeset: this publishes from a
released package, so that label would be wrong here.

Kept out of scope, deliberately: retiring '*', which needs the compatibility decision
above; and packages/spec, read but never edited.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…104 D2 param gate
Both functions were ablated repo-wide first. `seedFlowActionParams` turned out
to be pinned already — indirectly, through the REST route — but only on its
object-BOUND leg; `enforceActionParams` had no pin at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ess predicate
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 — 24 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 fddfc8db062d61ca68ba482531f5368326109554packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6a3d903a77f11d69f7779211eacbb68884e98254 — the merge of head ae512c7e73df6f32d45393355e0fea50048fbb7e into base fddfc8db062d61ca68ba482531f5368326109554, 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 6a3d903a77f11d69f7779211eacbb68884e98254 && git checkout 6a3d903a77f11d69f7779211eacbb68884e98254
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fddfc8db062d61ca68ba482531f5368326109554 ae512c7e73df6f32d45393355e0fea50048fbb7e && git checkout -B drift-repro fddfc8db062d61ca68ba482531f5368326109554 && git merge --no-ff ae512c7e73df6f32d45393355e0fea50048fbb7e
node scripts/docs-audit/affected-docs.mjs --json fddfc8db062d61ca68ba482531f5368326109554

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): route the flow param seeder through the single object-less action-key predicate - #15018

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates
Sep 3, 2026
Merged

fix(runtime): route the flow param seeder through the single object-less action-key predicate#15018
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14864

File face — declared, not discovered

This PR touches 5 paths, all in packages/runtime and .changeset:

  • packages/runtime/src/action-execution.ts
  • packages/runtime/src/action-owner-key-single-source.test.ts
  • packages/runtime/src/action-object-less-key-agreement.test.ts (new)
  • packages/runtime/src/action-params-enforcement.test.ts (new)
  • .changeset/object-less-action-key-one-predicate.md (new)

Card #13906 (p1 · security · computeExecCtx seams) was flagged as a possible
collision. It is not one: its committed face is packages/rest/src/rest-api-plugin.ts,
packages/rest/src/rest-server.ts, two packages/rest tests, a packages/qa test and an
ADR — zero files under packages/runtime. Read from its worktree's own diff against
the merge base, not inferred. No file is shared.

packages/spec was read, never edited. packages/rest/src/error-response.ts and
packages/cli/src/commands/generate.ts were not touched.

The headline claim on the card is FALSE, and the ablation is how I know

The card and its triage both say "neither seedFlowActionParams nor enforceActionParams
is named by any test"
, and both correctly refused to let anyone build on it. Ablated
repo-wide against packages/runtime (baseline 217 files / 3143 tests, all green), each
mutation proven on disk by blob hash and anchor counts, each restore proven by blob-hash
equality against the HEAD blob, under a trap … EXIT INT TERM on absolute paths:

ablationresult
seedFlowActionParams gutted to return { ...params }5 tests RED in http-dispatcher.actions-type-dispatch.test.ts
enforceActionParams replaced with an unconditional return null3143 passed, 0 failed

So the two functions are in opposite states, and the card's single sentence was wrong
about one of them and right about the other:

  • seedFlowActionParams was pinned all along — indirectly, through the REST route,
    in a file that never names it. Exactly the "pinned somewhere I did not look" case the
    method warning predicts. But every case in that file routes at a real object
    (/crm_lead/...), so its object-LESS leg — where the two predicates actually disagree —
    was the unpinned part. That is a sharper finding than "unpinned", and it is why the
    divergence survived: the ladder was pinned, the leg was not.
  • enforceActionParams is genuinely unpinned. The ADR-0104 D2 gate could stop
    existing entirely and nothing in the repo would notice. Its validator
    (validateActionParams) is thoroughly pinned in @objectstack/spec; the runtime gate
    wrapped around it was not, and a green validator says nothing about whether anything
    still calls it. Its only other mention outside the source is a manually-run
    platform-checklist clause. Confirmed by a repo-wide search for its one observable string
    Invalid action params — zero automated assertions, with a positive control that fired.

Is '*' dead, or merely unused today? — merely unused

This is the measurement the card said to make first, and it decides the direction.

  • Nothing first-party registers under '*', so the divergence is not producing wrong
    results in this repo today.
  • But actionHandlerObjectKeysdeliberately probes '*' last, with a docblock saying
    why: "kept last so a handler that user code registered directly against the wildcard
    still resolves."
    registerAction(objectName, actionName, handler) is public engine
    surface — user code can and does pass its own key.
  • And the route reaches it. objectName on this path is a raw URL path segment
    (domains/actions.ts), unvalidated, so POST /actions/*/theAction/theId resolves a
    global flow action through ownsRoute and lands in the divergence with objectName
    equal to '*'.

'*' is a live, deliberately-honoured legacy read path that happens to have no
first-party writer
. Retiring it is a compatibility decision about someone else's package,
with a retirement question attached (#3913). That is not a tidy-up this p3 is entitled to
make.

Direction taken: widen, and the divergence is narrower than the card says

seedFlowActionParams now asks isObjectLessActionKey(objectName) — the same predicate
dispatchFlowAction asks three lines away before deciding whether to send an object at
all. One predicate, one answer.

⚠️Correction to the card's anchor. The card describes the sibling as treating "'*'
and''" as object-less, implying two divergent inputs. Measured against origin/main,
it is exactly one. The old guard was objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY,
and its objectName && truthiness leg already covered '' (and undefined):

routed keyisObjectLessActionKeyold guard seeds an aliasagree?
'crm_lead'falseyes
'global'trueno
''trueno
'*'trueyes — seeds '*Id'

The observable defect is a params bag carrying a nonsense key spelled '*Id' beside
recordId, for a route the envelope on the previous line already called object-less.

The pin, red-first, naming which arm fired

action-object-less-key-agreement.test.ts run against unmodifiedorigin/main source:

❯ src/action-object-less-key-agreement.test.ts (6 tests | 2 failed)
× seeds no object alias for the object-less key '"*"'
× every object-less spelling lands the SAME bag as the canonical key
AssertionError: seedFlowActionParams seeded the alias key "*Id" for the object-less
route "*". … expected [ 'recordId', '*Id' ] to not include '*Id'
AssertionError: routing at "*" produced a different params bag:
expected { recordId: 'row_1', '*Id': 'row_1' } to deeply equal { recordId: 'row_1' }

The arm that fired is the negative arm, not a control. Specifically:

  • the anti-vacuity control (an object-BOUND route still seeds its alias key) passed
    the harness runs and the seeding path is live, so the negatives are not passing vacuously;
  • the 'global' and '' rows passed — which is the table above, measured rather than
    argued: only '*' diverged;
  • the '*' row failed with the message the pin was written to emit.

For the second pin, enforceActionParams, the red-first proof is the ablation itself:
re-running the A2 mutation with the new pin present turns 3 of its 5 tests red where
the whole 3143-test suite had stayed green — and the two that stay green are the
anti-vacuity control and the param-less pass-through, which that mutation genuinely does
not affect. Right arms, both directions.

Verification

All at final HEAD ae512c7e73, heavy runs through scripts/pm/os-verify-lock.sh, exits
captured before any pipe.

  • pnpm --filter @objectstack/runtime exec vitest run — 219 files / 3154 passed
    (baseline on origin/main was 217 / 3143; the delta is exactly the two new pin files).
  • pnpm --filter @objectstack/runtime typecheck (run by name, chains
    check:test-typecheck) — gate's own verdict:
    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) held in test-typecheck-debt.json.
    test-typecheck-debt.json is untouched — the new test files compile clean; nothing
    was added to the shrink-only ledger, no any, no ts-expect-error, no weakened assertion.
  • 31 gate families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
    at this HEAD (not from a hand-written diff): 28 green, 3 NOT MEASURED — each
    an exit 3, a gate's own PREREQUISITE NOT MET, which is not a red:
    • check:test-completeness — grades a saved turbo run test log; CI tees one, there is
      none locally, and the gate's own text says to record it as NOT MEASURED.
    • check:dual-build-cjs-loads — needs a whole-repo pnpm build first; that is CI's run.
    • scripts/pm/check-half-states.mjs — repo-scoped GitHub reads are 403 on this session
      (GET /rate_limit answers 200 with 15000 left; GET /repos/... answers 403). Its
      self-test half, pnpm check:pm-half-states, passed with 2062 cases.

pnpm lint — narrowed, and the narrowing is a measurement, declared as such. The
repo-wide eslint . --no-inline-config is CI's run. Locally I linted the 4 source paths and
prove the narrowing excluded nothing:

  1. the universe is read from eslint's own config, not guessed — this repo runs one
    eslint.config.mjs, and its own header states it "never enables type-aware linting (no
    parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not"

    (eslint.config.mjs:325-335);
  2. the file count is read from --format json: 4 files linted, 0 errors, 0 warnings;
  3. invariance follows from (1): with no type-aware program anywhere, no edit in this diff
    can move the verdict of a file it does not contain.

Scope

The enforceActionParams pin is a second file beyond the strict divergence fix, named here
because a coverage addition should never arrive unnamed. It is in scope: the card names that
function as half of its primary deliverable, the ablation proved it genuinely unpinned, no
other claim holds the file, and it adds no new gate surface. It changes no behaviour — it
pins the behaviour that is already there.

Changeset: patch on @objectstack/runtime — a behaviour change in a released package,
which is the fork dispatch-gates.mjs prints. Not skip-changeset: this publishes from a
released package, so that label would be wrong here.

Kept out of scope, deliberately: retiring '*', which needs the compatibility decision
above; and packages/spec, read but never edited.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…104 D2 param gate
Both functions were ablated repo-wide first. `seedFlowActionParams` turned out
to be pinned already — indirectly, through the REST route — but only on its
object-BOUND leg; `enforceActionParams` had no pin at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ess predicate
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 — 24 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 fddfc8db062d61ca68ba482531f5368326109554packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6a3d903a77f11d69f7779211eacbb68884e98254 — the merge of head ae512c7e73df6f32d45393355e0fea50048fbb7e into base fddfc8db062d61ca68ba482531f5368326109554, 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 6a3d903a77f11d69f7779211eacbb68884e98254 && git checkout 6a3d903a77f11d69f7779211eacbb68884e98254
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fddfc8db062d61ca68ba482531f5368326109554 ae512c7e73df6f32d45393355e0fea50048fbb7e && git checkout -B drift-repro fddfc8db062d61ca68ba482531f5368326109554 && git merge --no-ff ae512c7e73df6f32d45393355e0fea50048fbb7e
node scripts/docs-audit/affected-docs.mjs --json fddfc8db062d61ca68ba482531f5368326109554

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): route the flow param seeder through the single object-less action-key predicate - #15018

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates
Sep 3, 2026
Merged

fix(runtime): route the flow param seeder through the single object-less action-key predicate#15018
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14864

File face — declared, not discovered

This PR touches 5 paths, all in packages/runtime and .changeset:

  • packages/runtime/src/action-execution.ts
  • packages/runtime/src/action-owner-key-single-source.test.ts
  • packages/runtime/src/action-object-less-key-agreement.test.ts (new)
  • packages/runtime/src/action-params-enforcement.test.ts (new)
  • .changeset/object-less-action-key-one-predicate.md (new)

Card #13906 (p1 · security · computeExecCtx seams) was flagged as a possible
collision. It is not one: its committed face is packages/rest/src/rest-api-plugin.ts,
packages/rest/src/rest-server.ts, two packages/rest tests, a packages/qa test and an
ADR — zero files under packages/runtime. Read from its worktree's own diff against
the merge base, not inferred. No file is shared.

packages/spec was read, never edited. packages/rest/src/error-response.ts and
packages/cli/src/commands/generate.ts were not touched.

The headline claim on the card is FALSE, and the ablation is how I know

The card and its triage both say "neither seedFlowActionParams nor enforceActionParams
is named by any test"
, and both correctly refused to let anyone build on it. Ablated
repo-wide against packages/runtime (baseline 217 files / 3143 tests, all green), each
mutation proven on disk by blob hash and anchor counts, each restore proven by blob-hash
equality against the HEAD blob, under a trap … EXIT INT TERM on absolute paths:

ablationresult
seedFlowActionParams gutted to return { ...params }5 tests RED in http-dispatcher.actions-type-dispatch.test.ts
enforceActionParams replaced with an unconditional return null3143 passed, 0 failed

So the two functions are in opposite states, and the card's single sentence was wrong
about one of them and right about the other:

  • seedFlowActionParams was pinned all along — indirectly, through the REST route,
    in a file that never names it. Exactly the "pinned somewhere I did not look" case the
    method warning predicts. But every case in that file routes at a real object
    (/crm_lead/...), so its object-LESS leg — where the two predicates actually disagree —
    was the unpinned part. That is a sharper finding than "unpinned", and it is why the
    divergence survived: the ladder was pinned, the leg was not.
  • enforceActionParams is genuinely unpinned. The ADR-0104 D2 gate could stop
    existing entirely and nothing in the repo would notice. Its validator
    (validateActionParams) is thoroughly pinned in @objectstack/spec; the runtime gate
    wrapped around it was not, and a green validator says nothing about whether anything
    still calls it. Its only other mention outside the source is a manually-run
    platform-checklist clause. Confirmed by a repo-wide search for its one observable string
    Invalid action params — zero automated assertions, with a positive control that fired.

Is '*' dead, or merely unused today? — merely unused

This is the measurement the card said to make first, and it decides the direction.

  • Nothing first-party registers under '*', so the divergence is not producing wrong
    results in this repo today.
  • But actionHandlerObjectKeysdeliberately probes '*' last, with a docblock saying
    why: "kept last so a handler that user code registered directly against the wildcard
    still resolves."
    registerAction(objectName, actionName, handler) is public engine
    surface — user code can and does pass its own key.
  • And the route reaches it. objectName on this path is a raw URL path segment
    (domains/actions.ts), unvalidated, so POST /actions/*/theAction/theId resolves a
    global flow action through ownsRoute and lands in the divergence with objectName
    equal to '*'.

'*' is a live, deliberately-honoured legacy read path that happens to have no
first-party writer
. Retiring it is a compatibility decision about someone else's package,
with a retirement question attached (#3913). That is not a tidy-up this p3 is entitled to
make.

Direction taken: widen, and the divergence is narrower than the card says

seedFlowActionParams now asks isObjectLessActionKey(objectName) — the same predicate
dispatchFlowAction asks three lines away before deciding whether to send an object at
all. One predicate, one answer.

⚠️Correction to the card's anchor. The card describes the sibling as treating "'*'
and''" as object-less, implying two divergent inputs. Measured against origin/main,
it is exactly one. The old guard was objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY,
and its objectName && truthiness leg already covered '' (and undefined):

routed keyisObjectLessActionKeyold guard seeds an aliasagree?
'crm_lead'falseyes
'global'trueno
''trueno
'*'trueyes — seeds '*Id'

The observable defect is a params bag carrying a nonsense key spelled '*Id' beside
recordId, for a route the envelope on the previous line already called object-less.

The pin, red-first, naming which arm fired

action-object-less-key-agreement.test.ts run against unmodifiedorigin/main source:

❯ src/action-object-less-key-agreement.test.ts (6 tests | 2 failed)
× seeds no object alias for the object-less key '"*"'
× every object-less spelling lands the SAME bag as the canonical key
AssertionError: seedFlowActionParams seeded the alias key "*Id" for the object-less
route "*". … expected [ 'recordId', '*Id' ] to not include '*Id'
AssertionError: routing at "*" produced a different params bag:
expected { recordId: 'row_1', '*Id': 'row_1' } to deeply equal { recordId: 'row_1' }

The arm that fired is the negative arm, not a control. Specifically:

  • the anti-vacuity control (an object-BOUND route still seeds its alias key) passed
    the harness runs and the seeding path is live, so the negatives are not passing vacuously;
  • the 'global' and '' rows passed — which is the table above, measured rather than
    argued: only '*' diverged;
  • the '*' row failed with the message the pin was written to emit.

For the second pin, enforceActionParams, the red-first proof is the ablation itself:
re-running the A2 mutation with the new pin present turns 3 of its 5 tests red where
the whole 3143-test suite had stayed green — and the two that stay green are the
anti-vacuity control and the param-less pass-through, which that mutation genuinely does
not affect. Right arms, both directions.

Verification

All at final HEAD ae512c7e73, heavy runs through scripts/pm/os-verify-lock.sh, exits
captured before any pipe.

  • pnpm --filter @objectstack/runtime exec vitest run — 219 files / 3154 passed
    (baseline on origin/main was 217 / 3143; the delta is exactly the two new pin files).
  • pnpm --filter @objectstack/runtime typecheck (run by name, chains
    check:test-typecheck) — gate's own verdict:
    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) held in test-typecheck-debt.json.
    test-typecheck-debt.json is untouched — the new test files compile clean; nothing
    was added to the shrink-only ledger, no any, no ts-expect-error, no weakened assertion.
  • 31 gate families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
    at this HEAD (not from a hand-written diff): 28 green, 3 NOT MEASURED — each
    an exit 3, a gate's own PREREQUISITE NOT MET, which is not a red:
    • check:test-completeness — grades a saved turbo run test log; CI tees one, there is
      none locally, and the gate's own text says to record it as NOT MEASURED.
    • check:dual-build-cjs-loads — needs a whole-repo pnpm build first; that is CI's run.
    • scripts/pm/check-half-states.mjs — repo-scoped GitHub reads are 403 on this session
      (GET /rate_limit answers 200 with 15000 left; GET /repos/... answers 403). Its
      self-test half, pnpm check:pm-half-states, passed with 2062 cases.

pnpm lint — narrowed, and the narrowing is a measurement, declared as such. The
repo-wide eslint . --no-inline-config is CI's run. Locally I linted the 4 source paths and
prove the narrowing excluded nothing:

  1. the universe is read from eslint's own config, not guessed — this repo runs one
    eslint.config.mjs, and its own header states it "never enables type-aware linting (no
    parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not"

    (eslint.config.mjs:325-335);
  2. the file count is read from --format json: 4 files linted, 0 errors, 0 warnings;
  3. invariance follows from (1): with no type-aware program anywhere, no edit in this diff
    can move the verdict of a file it does not contain.

Scope

The enforceActionParams pin is a second file beyond the strict divergence fix, named here
because a coverage addition should never arrive unnamed. It is in scope: the card names that
function as half of its primary deliverable, the ablation proved it genuinely unpinned, no
other claim holds the file, and it adds no new gate surface. It changes no behaviour — it
pins the behaviour that is already there.

Changeset: patch on @objectstack/runtime — a behaviour change in a released package,
which is the fork dispatch-gates.mjs prints. Not skip-changeset: this publishes from a
released package, so that label would be wrong here.

Kept out of scope, deliberately: retiring '*', which needs the compatibility decision
above; and packages/spec, read but never edited.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…104 D2 param gate
Both functions were ablated repo-wide first. `seedFlowActionParams` turned out
to be pinned already — indirectly, through the REST route — but only on its
object-BOUND leg; `enforceActionParams` had no pin at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ess predicate
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 — 24 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 fddfc8db062d61ca68ba482531f5368326109554packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6a3d903a77f11d69f7779211eacbb68884e98254 — the merge of head ae512c7e73df6f32d45393355e0fea50048fbb7e into base fddfc8db062d61ca68ba482531f5368326109554, 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 6a3d903a77f11d69f7779211eacbb68884e98254 && git checkout 6a3d903a77f11d69f7779211eacbb68884e98254
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fddfc8db062d61ca68ba482531f5368326109554 ae512c7e73df6f32d45393355e0fea50048fbb7e && git checkout -B drift-repro fddfc8db062d61ca68ba482531f5368326109554 && git merge --no-ff ae512c7e73df6f32d45393355e0fea50048fbb7e
node scripts/docs-audit/affected-docs.mjs --json fddfc8db062d61ca68ba482531f5368326109554

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(runtime): route the flow param seeder through the single object-less action-key predicate - #15018

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates
Sep 3, 2026
Merged

fix(runtime): route the flow param seeder through the single object-less action-key predicate#15018
os-trump merged 3 commits into
mainfrom
claude/issue-14864-objectless-action-key-predicates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14864

File face — declared, not discovered

This PR touches 5 paths, all in packages/runtime and .changeset:

  • packages/runtime/src/action-execution.ts
  • packages/runtime/src/action-owner-key-single-source.test.ts
  • packages/runtime/src/action-object-less-key-agreement.test.ts (new)
  • packages/runtime/src/action-params-enforcement.test.ts (new)
  • .changeset/object-less-action-key-one-predicate.md (new)

Card #13906 (p1 · security · computeExecCtx seams) was flagged as a possible
collision. It is not one: its committed face is packages/rest/src/rest-api-plugin.ts,
packages/rest/src/rest-server.ts, two packages/rest tests, a packages/qa test and an
ADR — zero files under packages/runtime. Read from its worktree's own diff against
the merge base, not inferred. No file is shared.

packages/spec was read, never edited. packages/rest/src/error-response.ts and
packages/cli/src/commands/generate.ts were not touched.

The headline claim on the card is FALSE, and the ablation is how I know

The card and its triage both say "neither seedFlowActionParams nor enforceActionParams
is named by any test"
, and both correctly refused to let anyone build on it. Ablated
repo-wide against packages/runtime (baseline 217 files / 3143 tests, all green), each
mutation proven on disk by blob hash and anchor counts, each restore proven by blob-hash
equality against the HEAD blob, under a trap … EXIT INT TERM on absolute paths:

ablationresult
seedFlowActionParams gutted to return { ...params }5 tests RED in http-dispatcher.actions-type-dispatch.test.ts
enforceActionParams replaced with an unconditional return null3143 passed, 0 failed

So the two functions are in opposite states, and the card's single sentence was wrong
about one of them and right about the other:

  • seedFlowActionParams was pinned all along — indirectly, through the REST route,
    in a file that never names it. Exactly the "pinned somewhere I did not look" case the
    method warning predicts. But every case in that file routes at a real object
    (/crm_lead/...), so its object-LESS leg — where the two predicates actually disagree —
    was the unpinned part. That is a sharper finding than "unpinned", and it is why the
    divergence survived: the ladder was pinned, the leg was not.
  • enforceActionParams is genuinely unpinned. The ADR-0104 D2 gate could stop
    existing entirely and nothing in the repo would notice. Its validator
    (validateActionParams) is thoroughly pinned in @objectstack/spec; the runtime gate
    wrapped around it was not, and a green validator says nothing about whether anything
    still calls it. Its only other mention outside the source is a manually-run
    platform-checklist clause. Confirmed by a repo-wide search for its one observable string
    Invalid action params — zero automated assertions, with a positive control that fired.

Is '*' dead, or merely unused today? — merely unused

This is the measurement the card said to make first, and it decides the direction.

  • Nothing first-party registers under '*', so the divergence is not producing wrong
    results in this repo today.
  • But actionHandlerObjectKeysdeliberately probes '*' last, with a docblock saying
    why: "kept last so a handler that user code registered directly against the wildcard
    still resolves."
    registerAction(objectName, actionName, handler) is public engine
    surface — user code can and does pass its own key.
  • And the route reaches it. objectName on this path is a raw URL path segment
    (domains/actions.ts), unvalidated, so POST /actions/*/theAction/theId resolves a
    global flow action through ownsRoute and lands in the divergence with objectName
    equal to '*'.

'*' is a live, deliberately-honoured legacy read path that happens to have no
first-party writer
. Retiring it is a compatibility decision about someone else's package,
with a retirement question attached (#3913). That is not a tidy-up this p3 is entitled to
make.

Direction taken: widen, and the divergence is narrower than the card says

seedFlowActionParams now asks isObjectLessActionKey(objectName) — the same predicate
dispatchFlowAction asks three lines away before deciding whether to send an object at
all. One predicate, one answer.

⚠️Correction to the card's anchor. The card describes the sibling as treating "'*'
and''" as object-less, implying two divergent inputs. Measured against origin/main,
it is exactly one. The old guard was objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY,
and its objectName && truthiness leg already covered '' (and undefined):

routed keyisObjectLessActionKeyold guard seeds an aliasagree?
'crm_lead'falseyes
'global'trueno
''trueno
'*'trueyes — seeds '*Id'

The observable defect is a params bag carrying a nonsense key spelled '*Id' beside
recordId, for a route the envelope on the previous line already called object-less.

The pin, red-first, naming which arm fired

action-object-less-key-agreement.test.ts run against unmodifiedorigin/main source:

❯ src/action-object-less-key-agreement.test.ts (6 tests | 2 failed)
× seeds no object alias for the object-less key '"*"'
× every object-less spelling lands the SAME bag as the canonical key
AssertionError: seedFlowActionParams seeded the alias key "*Id" for the object-less
route "*". … expected [ 'recordId', '*Id' ] to not include '*Id'
AssertionError: routing at "*" produced a different params bag:
expected { recordId: 'row_1', '*Id': 'row_1' } to deeply equal { recordId: 'row_1' }

The arm that fired is the negative arm, not a control. Specifically:

  • the anti-vacuity control (an object-BOUND route still seeds its alias key) passed
    the harness runs and the seeding path is live, so the negatives are not passing vacuously;
  • the 'global' and '' rows passed — which is the table above, measured rather than
    argued: only '*' diverged;
  • the '*' row failed with the message the pin was written to emit.

For the second pin, enforceActionParams, the red-first proof is the ablation itself:
re-running the A2 mutation with the new pin present turns 3 of its 5 tests red where
the whole 3143-test suite had stayed green — and the two that stay green are the
anti-vacuity control and the param-less pass-through, which that mutation genuinely does
not affect. Right arms, both directions.

Verification

All at final HEAD ae512c7e73, heavy runs through scripts/pm/os-verify-lock.sh, exits
captured before any pipe.

  • pnpm --filter @objectstack/runtime exec vitest run — 219 files / 3154 passed
    (baseline on origin/main was 217 / 3143; the delta is exactly the two new pin files).
  • pnpm --filter @objectstack/runtime typecheck (run by name, chains
    check:test-typecheck) — gate's own verdict:
    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) held in test-typecheck-debt.json.
    test-typecheck-debt.json is untouched — the new test files compile clean; nothing
    was added to the shrink-only ledger, no any, no ts-expect-error, no weakened assertion.
  • 31 gate families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
    at this HEAD (not from a hand-written diff): 28 green, 3 NOT MEASURED — each
    an exit 3, a gate's own PREREQUISITE NOT MET, which is not a red:
    • check:test-completeness — grades a saved turbo run test log; CI tees one, there is
      none locally, and the gate's own text says to record it as NOT MEASURED.
    • check:dual-build-cjs-loads — needs a whole-repo pnpm build first; that is CI's run.
    • scripts/pm/check-half-states.mjs — repo-scoped GitHub reads are 403 on this session
      (GET /rate_limit answers 200 with 15000 left; GET /repos/... answers 403). Its
      self-test half, pnpm check:pm-half-states, passed with 2062 cases.

pnpm lint — narrowed, and the narrowing is a measurement, declared as such. The
repo-wide eslint . --no-inline-config is CI's run. Locally I linted the 4 source paths and
prove the narrowing excluded nothing:

  1. the universe is read from eslint's own config, not guessed — this repo runs one
    eslint.config.mjs, and its own header states it "never enables type-aware linting (no
    parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not"

    (eslint.config.mjs:325-335);
  2. the file count is read from --format json: 4 files linted, 0 errors, 0 warnings;
  3. invariance follows from (1): with no type-aware program anywhere, no edit in this diff
    can move the verdict of a file it does not contain.

Scope

The enforceActionParams pin is a second file beyond the strict divergence fix, named here
because a coverage addition should never arrive unnamed. It is in scope: the card names that
function as half of its primary deliverable, the ablation proved it genuinely unpinned, no
other claim holds the file, and it adds no new gate surface. It changes no behaviour — it
pins the behaviour that is already there.

Changeset: patch on @objectstack/runtime — a behaviour change in a released package,
which is the fork dispatch-gates.mjs prints. Not skip-changeset: this publishes from a
released package, so that label would be wrong here.

Kept out of scope, deliberately: retiring '*', which needs the compatibility decision
above; and packages/spec, read but never edited.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…104 D2 param gate
Both functions were ablated repo-wide first. `seedFlowActionParams` turned out
to be pinned already — indirectly, through the REST route — but only on its
object-BOUND leg; `enforceActionParams` had no pin at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ess predicate
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 — 24 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 fddfc8db062d61ca68ba482531f5368326109554packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6a3d903a77f11d69f7779211eacbb68884e98254 — the merge of head ae512c7e73df6f32d45393355e0fea50048fbb7e into base fddfc8db062d61ca68ba482531f5368326109554, 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 6a3d903a77f11d69f7779211eacbb68884e98254 && git checkout 6a3d903a77f11d69f7779211eacbb68884e98254
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fddfc8db062d61ca68ba482531f5368326109554 ae512c7e73df6f32d45393355e0fea50048fbb7e && git checkout -B drift-repro fddfc8db062d61ca68ba482531f5368326109554 && git merge --no-ff ae512c7e73df6f32d45393355e0fea50048fbb7e
node scripts/docs-audit/affected-docs.mjs --json fddfc8db062d61ca68ba482531f5368326109554

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude