refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts - #14861

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals
Sep 3, 2026
Merged

refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts#14861
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14678

File face: packages/runtime/src/action-execution.ts, and nothing else.packages/runtime/src/sandbox/** is untouched — PR #14826 is armed on that face with card #14760 queued behind it. packages/spec is read-only here: it owns one site in this class and that site is named below rather than edited.

Re-measured before editing, because the card said to

$ git grep -n "'global'" origin/main -- packages/runtime/src/action-execution.ts
origin/main:packages/runtime/src/action-execution.ts:641: if (objectName && objectName !== 'global') {
origin/main:packages/runtime/src/action-execution.ts:1016: const key = `${where.objectName ?? 'global'}/${where.actionName ?? action?.name ?? 'action'}`;
origin/main:packages/runtime/src/action-execution.ts:1490: * legacy `object` field, else the `'global'` wildcard).

Three, at the three line numbers the card's current table gives. Neither of the two corrections that moved this card applies any more: the withdrawn fourth site at :1539 is gone, and the numbers have not shifted since 75adf11.

Widened past the single-quote spelling, in case the count was an artefact of the grep rather than of the tree:

$ git grep -nE "(['\"`])global\1|\bglobal\b" origin/main -- packages/runtime/src/action-execution.ts

adds only :962pass \undefined` for a global parent object schema` — the English adjective, not the token. So three is the real population, not three-of-some-quote-style.

What changed

SiteKindCall
seedFlowActionParamslive comparisonconverged
enforceActionParamswarn-once log keyconverged, argued below
collectActionDeclarations docblockprose, plus a second defectconverged

seedFlowActionParams is the one that matters — a live comparison deciding whether an object-derived Id param key is seeded into an action's bag. Pure substitution: objectName !== GLOBAL_ACTION_OBJECT_KEY. The predicate is byte-for-byte the same test, so the legacy '*' key still falls through exactly as it did (see Residue).

enforceActionParams is the judgement the card asked to be made out loud. The case for leaving a literal is that a warn-once dedup key — which is also interpolated into the operator-facing [action-params] … line — must never fail to render. That argument does not survive contact with what the alternative actually is: GLOBAL_ACTION_OBJECT_KEY is a module-scope const string, already imported into this file, already interpolated by the sibling site. It cannot fail to render either. What a drift there really costs is an operator grepping logs by the key the engine now uses and silently missing these lines. So: converged. Note the deliberate asymmetry left in place one interpolation to the right — the ?? 'action' fallback in the same template stays a bare literal, because it is a display placeholder with no constant behind it and no engine concept underneath. Same token shape, different role; that is the whole distinction this card turns on.

The docblock carried a second defect independent of the literal: it called the key "the 'global' wildcard", contradicting action-governance.ts — "engine.executeAction is an exact-string Map lookup with no wildcard semantics". Both defects close together, and the replacement is not invented: it is the phrasing the sibling docblock 48 lines below already used, so the file now says the same thing in both places.

grep -c for all three quote spellings of the token in this file is now 0, 0, 0.

The sweep, and how true instances were told from same-token noise

The token is everywhere — 626 occurrences across 160 source files. Nearly all of them are a different vocabulary that happens to share a word, and the difference is invisible from the token:

$ git grep -c "'global'" origin/main -- 'packages/**/*.ts' 'packages/**/*.tsx' \
'apps/**/*.ts' 'apps/**/*.tsx' 'examples/**/*.ts' 'scripts/**' | awk -F: '{s+=$2} END {print s, NR}'
626 160

The discriminator is not the token, it is the role: does this value become, or get compared against, the object half of the engine's objectName:actionName registration key that executeAction looks up? Applying it splits the population into one true class and five unrelated vocabularies:

  • ADR-0120 uniqueness scope (unique: 'global' | 'organization') — by far the largest bucket, and the one that would have swallowed a token-level sweep whole.
  • Settings / storage / permission scope (scope: 'global' | 'tenant' | 'user'), lifecycle and tenancy scope, automation lockScope, and app payload fields such as the showcase action's own scope: 'global' return value.

None of those five ever reaches an action registration key. That leaves the true class — every site where the value really is the object-less action key:

SiteVerdict
objectql/src/action-governance.ts:61the declaration itself — the single source, not a defect
objectql/src/action-governance.ts:87prose quoting the anti-pattern it replaced; correct as written
runtime/src/action-execution.ts ×3converged here
cli/src/commands/lint.ts:321true instance — left, see below
client/src/index.ts:4269true instance — left, see below
spec/src/stack.zod.ts:1537deliberate second spelling — left, see below
runtime/src/domains/actions.ts ×4prose only; live code already converged — left
cli/src/lint/hook-body-lowering.ts:279, cli/src/utils/lower-callables.ts:198false positives by role

What was deliberately left, and why

packages/cli/src/commands/lint.ts:321 is a genuine instance — it builds the engine registration key with the same objectName-else-literal ladder, and its own comment says so ("it is the literal the engine really registers under"). It is not converged here for a reason that is not taste: that file is claimed.#14669 is open and pm:dispatched on packages/cli/src/commands/lint.ts:298 — twenty-three lines above, inside the same docblock. Editing around another seat's live claim buys a conflict and nothing else. #14669's own body also states the precedent for the cross-package half: pulling @objectstack/cli in "would pull both into turbo's affected set and add a verification surface to a p3 convergence card".

packages/client/src/index.ts:4269 (invokeGlobal dispatching to invoke('global', …)) is a true instance that cannot be converged as things stand. @objectstack/client depends on @objectstack/core and @objectstack/spec and nothing else — it has no path to GLOBAL_ACTION_OBJECT_KEY without giving the browser SDK an engine dependency. That is an architecture decision, not a p3 cleanup.

packages/spec/src/stack.zod.ts:1537 re-declares the value as GLOBAL_ACTION_SCOPE with a docblock stating exactly why — "spelled here rather than imported because the spec sits below every runtime package". Off-limits to this seat by fence, and it is a documented second spelling rather than an accidental one. Naming it, not touching it.

packages/runtime/src/domains/actions.ts carries four quoted spellings, all in comments; its live code already resolves through actionExec.GLOBAL_ACTION_OBJECT_KEY at :431. Two of the four describe the literal URL path segment/actions/global/:action, which is a different thing that happens to share the word; the other two narrate the historical #3913 defect and are statements about a measured past. None carries the wildcard contradiction. Left, and the reason is on the record here rather than in silence.

The two packages/cli lowering sites are false positives by role, which is worth showing rather than asserting. 'global' there is an ownerLabel that becomes a generated handler name prefix (global_myAction) for the callable registry, and a diagnostic string. It never becomes an objectName. If the constant moved, those labels would produce a differently-named handler ref — consistently, within the same lowering pass. No coupling, so no defect.

Can the next one be made loud?

Yes, and cheaply enough that declining would have been the harder argument. action-owner-key-single-source.test.ts already exists in this package for exactly this weld — half A pins the behaviour, half B pins the structure. It gains a half C that reads action-execution.ts and fails if any quote spelling of the key is written by hand again.

The forbidden spelling is derived from the constant, not hard-coded:

constBARE_LITERALS: readonlystring[]=[`'${GLOBAL_ACTION_OBJECT_KEY}'`,`"${GLOBAL_ACTION_OBJECT_KEY}"`,`\`${GLOBAL_ACTION_OBJECT_KEY}\``,];

A hard-coded 'global' there would have been a fourth copy of the very literal the file exists to forbid, going stale in the same silence. Derived, the guard follows the constant wherever it goes.

This is proportionate for a p3 because it costs no new verification surface: an existing test file, in the affected package, reading a file it already reads, under gate families this card already owes.

It was verified to fail, twice, for two different reasons — a guard nobody has watched go red is a guard nobody has tested:

MutationExpectedObserved
re-inline the literal at the live comparisonredred — tripped the anti-vacuity control first
re-inline at the log key, leaving the control text intactred on the negative armred, with the intended message

Both legs proved the mutation reached disk before reading any result (injected-text and removed-text counts, plus a git hash-object comparison against the HEAD blob), restored with git checkout HEAD --, and proved the restore byte-identical (git diff HEAD empty, hash equal to the HEAD blob) before re-running green. No rebuild was needed for either leg and none is claimed: half C reads source text at runtime, and the constant it derives from comes from @objectstack/objectql's already-built dist, which no mutation here touches.

Changeset fork

patch on @objectstack/runtime, not skip-changeset.packages/runtime's files whitelist is ["dist", "README.md", "CHANGELOG.md"]src is not published raw, but dist is built from it, so the emitted output moves even though the behaviour does not. That is outside the skip-changeset criterion, which needs a diff that publishes nothing from any package. Nothing reaches the published entry either — no export, signature or type changed, and action-execution.ts is not re-exported from packages/runtime/src/index.ts — which is what keeps it below minor. This is the same fork and the same measurement PR #14667 took for this package on this file.

Readings — all at d1ec90e7, the head of this branch

Dependency closure built first (pnpm --filter '@objectstack/runtime^...' build), so nothing below is a stale-dist reading.

  • pnpm --filter @objectstack/runtime typecheck — exit 0. Run by name, so it chained the second link: 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 (shrink-only and identity-pinned). Nothing was added to that ledger. The edited test file is not in it, and tsc -p tsconfig.test.json --listFiles reaches it — so this is a measurement of the new code, not a green that skipped it.
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 action23 files, 366 tests, all passing. Half C is in the verbose transcript, so it ran rather than being collected and skipped.
  • Fourteen no-build gate families re-derived from the real diff, all green, each quoted by its own verdict line rather than by an exit code: nul-bytes (OK … no raw ASCII control bytes), swallow-census-controls, dispatcher-error-vocabulary, engine-double-contract (OK — 759 pinned, 133 in the DEBT ledger, 3 exempt), where-matcher (332 matcher(s) discovered, 332 answer … correctly), cross-package-test-inputs (OK: 25 package(s) read outside themselves, all declared), test-source-alias, published-files, doc-authoring, objectql-double-limit, query-options-erasure, changeset-gate-self-tests, objectui-changeset, pm-half-states.

The family list was re-derived from the actual change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack rather than from the dispatch note: 36 families, 31 by path and 7 by change kind. The heavy remainder — the full lint farm, the workspace typecheck, and check:type-check-debt --re-measure, which needs the whole workspace closure built — is left to CI, which runs it once regardless.

Residue, recorded rather than fixed

seedFlowActionParams guards with objectName !== GLOBAL_ACTION_OBJECT_KEY, but isObjectLessActionKey — exported from the same module and already imported into this file — also treats the legacy '*' key and the empty string as object-less. A route resolved at '*' therefore reaches the branch and seeds a *Id param key. Inert in practice, since #3913 left nothing registering under '*', and correcting it would be a behaviour change rather than a spelling one, so it is deliberately not in this PR. Neither of the two functions edited here is named by any test in the repo, which is the other half of why the drift was invisible. Both observations are in the report to PM; the GitHub search quota was exhausted at the time, so the mandatory dedup search for a new card could not be run and filing blind would have been worse than handing it over.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…14678)
`GLOBAL_ACTION_OBJECT_KEY` exists so the object-less action-registration key
is written once. #14422 converged the owner-key ladder and the ObjectQL
plugin's copy of it; three bare `'global'` spellings elsewhere in
`packages/runtime/src/action-execution.ts` were never in that card's path,
because the runtime fence it built was a re-export plus a delegating alias.
All three are equal in value to the constant today — which is the defect, not
a mitigation: it is what made them invisible to every test in the repo, and
what would have let them part from the constant in silence.
- `seedFlowActionParams`: the live comparison deciding whether an
object-derived `<object>Id` param key is seeded. Substitution only — the
predicate is unchanged, so `'*'` still falls through exactly as before.
- `enforceActionParams`: the warn-once dedup key, also interpolated into the
operator-facing `[action-params] <key>: …` line.
- `collectActionDeclarations`'s docblock, which also called the key "the
wildcard" against `action-governance.ts`'s "no wildcard semantics". It now
reads as the sibling docblock 48 lines below already did.
`action-owner-key-single-source.test.ts` gains a half C that reads
`action-execution.ts` and fails if any quote spelling is written out by hand
again. The forbidden spelling is derived from the constant, so the guard is
not itself another copy of the literal it forbids.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

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

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

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33742671888 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA(日志不可读,点进 job 看)

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 73ad0bbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14678-action-execution-global-literals branch September 3, 2026 10:46
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

3 participants

@os-trump@os-project-manager@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

refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts - #14861

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals
Sep 3, 2026
Merged

refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts#14861
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14678

File face: packages/runtime/src/action-execution.ts, and nothing else.packages/runtime/src/sandbox/** is untouched — PR #14826 is armed on that face with card #14760 queued behind it. packages/spec is read-only here: it owns one site in this class and that site is named below rather than edited.

Re-measured before editing, because the card said to

$ git grep -n "'global'" origin/main -- packages/runtime/src/action-execution.ts
origin/main:packages/runtime/src/action-execution.ts:641: if (objectName && objectName !== 'global') {
origin/main:packages/runtime/src/action-execution.ts:1016: const key = `${where.objectName ?? 'global'}/${where.actionName ?? action?.name ?? 'action'}`;
origin/main:packages/runtime/src/action-execution.ts:1490: * legacy `object` field, else the `'global'` wildcard).

Three, at the three line numbers the card's current table gives. Neither of the two corrections that moved this card applies any more: the withdrawn fourth site at :1539 is gone, and the numbers have not shifted since 75adf11.

Widened past the single-quote spelling, in case the count was an artefact of the grep rather than of the tree:

$ git grep -nE "(['\"`])global\1|\bglobal\b" origin/main -- packages/runtime/src/action-execution.ts

adds only :962pass \undefined` for a global parent object schema` — the English adjective, not the token. So three is the real population, not three-of-some-quote-style.

What changed

SiteKindCall
seedFlowActionParamslive comparisonconverged
enforceActionParamswarn-once log keyconverged, argued below
collectActionDeclarations docblockprose, plus a second defectconverged

seedFlowActionParams is the one that matters — a live comparison deciding whether an object-derived Id param key is seeded into an action's bag. Pure substitution: objectName !== GLOBAL_ACTION_OBJECT_KEY. The predicate is byte-for-byte the same test, so the legacy '*' key still falls through exactly as it did (see Residue).

enforceActionParams is the judgement the card asked to be made out loud. The case for leaving a literal is that a warn-once dedup key — which is also interpolated into the operator-facing [action-params] … line — must never fail to render. That argument does not survive contact with what the alternative actually is: GLOBAL_ACTION_OBJECT_KEY is a module-scope const string, already imported into this file, already interpolated by the sibling site. It cannot fail to render either. What a drift there really costs is an operator grepping logs by the key the engine now uses and silently missing these lines. So: converged. Note the deliberate asymmetry left in place one interpolation to the right — the ?? 'action' fallback in the same template stays a bare literal, because it is a display placeholder with no constant behind it and no engine concept underneath. Same token shape, different role; that is the whole distinction this card turns on.

The docblock carried a second defect independent of the literal: it called the key "the 'global' wildcard", contradicting action-governance.ts — "engine.executeAction is an exact-string Map lookup with no wildcard semantics". Both defects close together, and the replacement is not invented: it is the phrasing the sibling docblock 48 lines below already used, so the file now says the same thing in both places.

grep -c for all three quote spellings of the token in this file is now 0, 0, 0.

The sweep, and how true instances were told from same-token noise

The token is everywhere — 626 occurrences across 160 source files. Nearly all of them are a different vocabulary that happens to share a word, and the difference is invisible from the token:

$ git grep -c "'global'" origin/main -- 'packages/**/*.ts' 'packages/**/*.tsx' \
'apps/**/*.ts' 'apps/**/*.tsx' 'examples/**/*.ts' 'scripts/**' | awk -F: '{s+=$2} END {print s, NR}'
626 160

The discriminator is not the token, it is the role: does this value become, or get compared against, the object half of the engine's objectName:actionName registration key that executeAction looks up? Applying it splits the population into one true class and five unrelated vocabularies:

  • ADR-0120 uniqueness scope (unique: 'global' | 'organization') — by far the largest bucket, and the one that would have swallowed a token-level sweep whole.
  • Settings / storage / permission scope (scope: 'global' | 'tenant' | 'user'), lifecycle and tenancy scope, automation lockScope, and app payload fields such as the showcase action's own scope: 'global' return value.

None of those five ever reaches an action registration key. That leaves the true class — every site where the value really is the object-less action key:

SiteVerdict
objectql/src/action-governance.ts:61the declaration itself — the single source, not a defect
objectql/src/action-governance.ts:87prose quoting the anti-pattern it replaced; correct as written
runtime/src/action-execution.ts ×3converged here
cli/src/commands/lint.ts:321true instance — left, see below
client/src/index.ts:4269true instance — left, see below
spec/src/stack.zod.ts:1537deliberate second spelling — left, see below
runtime/src/domains/actions.ts ×4prose only; live code already converged — left
cli/src/lint/hook-body-lowering.ts:279, cli/src/utils/lower-callables.ts:198false positives by role

What was deliberately left, and why

packages/cli/src/commands/lint.ts:321 is a genuine instance — it builds the engine registration key with the same objectName-else-literal ladder, and its own comment says so ("it is the literal the engine really registers under"). It is not converged here for a reason that is not taste: that file is claimed.#14669 is open and pm:dispatched on packages/cli/src/commands/lint.ts:298 — twenty-three lines above, inside the same docblock. Editing around another seat's live claim buys a conflict and nothing else. #14669's own body also states the precedent for the cross-package half: pulling @objectstack/cli in "would pull both into turbo's affected set and add a verification surface to a p3 convergence card".

packages/client/src/index.ts:4269 (invokeGlobal dispatching to invoke('global', …)) is a true instance that cannot be converged as things stand. @objectstack/client depends on @objectstack/core and @objectstack/spec and nothing else — it has no path to GLOBAL_ACTION_OBJECT_KEY without giving the browser SDK an engine dependency. That is an architecture decision, not a p3 cleanup.

packages/spec/src/stack.zod.ts:1537 re-declares the value as GLOBAL_ACTION_SCOPE with a docblock stating exactly why — "spelled here rather than imported because the spec sits below every runtime package". Off-limits to this seat by fence, and it is a documented second spelling rather than an accidental one. Naming it, not touching it.

packages/runtime/src/domains/actions.ts carries four quoted spellings, all in comments; its live code already resolves through actionExec.GLOBAL_ACTION_OBJECT_KEY at :431. Two of the four describe the literal URL path segment/actions/global/:action, which is a different thing that happens to share the word; the other two narrate the historical #3913 defect and are statements about a measured past. None carries the wildcard contradiction. Left, and the reason is on the record here rather than in silence.

The two packages/cli lowering sites are false positives by role, which is worth showing rather than asserting. 'global' there is an ownerLabel that becomes a generated handler name prefix (global_myAction) for the callable registry, and a diagnostic string. It never becomes an objectName. If the constant moved, those labels would produce a differently-named handler ref — consistently, within the same lowering pass. No coupling, so no defect.

Can the next one be made loud?

Yes, and cheaply enough that declining would have been the harder argument. action-owner-key-single-source.test.ts already exists in this package for exactly this weld — half A pins the behaviour, half B pins the structure. It gains a half C that reads action-execution.ts and fails if any quote spelling of the key is written by hand again.

The forbidden spelling is derived from the constant, not hard-coded:

constBARE_LITERALS: readonlystring[]=[`'${GLOBAL_ACTION_OBJECT_KEY}'`,`"${GLOBAL_ACTION_OBJECT_KEY}"`,`\`${GLOBAL_ACTION_OBJECT_KEY}\``,];

A hard-coded 'global' there would have been a fourth copy of the very literal the file exists to forbid, going stale in the same silence. Derived, the guard follows the constant wherever it goes.

This is proportionate for a p3 because it costs no new verification surface: an existing test file, in the affected package, reading a file it already reads, under gate families this card already owes.

It was verified to fail, twice, for two different reasons — a guard nobody has watched go red is a guard nobody has tested:

MutationExpectedObserved
re-inline the literal at the live comparisonredred — tripped the anti-vacuity control first
re-inline at the log key, leaving the control text intactred on the negative armred, with the intended message

Both legs proved the mutation reached disk before reading any result (injected-text and removed-text counts, plus a git hash-object comparison against the HEAD blob), restored with git checkout HEAD --, and proved the restore byte-identical (git diff HEAD empty, hash equal to the HEAD blob) before re-running green. No rebuild was needed for either leg and none is claimed: half C reads source text at runtime, and the constant it derives from comes from @objectstack/objectql's already-built dist, which no mutation here touches.

Changeset fork

patch on @objectstack/runtime, not skip-changeset.packages/runtime's files whitelist is ["dist", "README.md", "CHANGELOG.md"]src is not published raw, but dist is built from it, so the emitted output moves even though the behaviour does not. That is outside the skip-changeset criterion, which needs a diff that publishes nothing from any package. Nothing reaches the published entry either — no export, signature or type changed, and action-execution.ts is not re-exported from packages/runtime/src/index.ts — which is what keeps it below minor. This is the same fork and the same measurement PR #14667 took for this package on this file.

Readings — all at d1ec90e7, the head of this branch

Dependency closure built first (pnpm --filter '@objectstack/runtime^...' build), so nothing below is a stale-dist reading.

  • pnpm --filter @objectstack/runtime typecheck — exit 0. Run by name, so it chained the second link: 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 (shrink-only and identity-pinned). Nothing was added to that ledger. The edited test file is not in it, and tsc -p tsconfig.test.json --listFiles reaches it — so this is a measurement of the new code, not a green that skipped it.
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 action23 files, 366 tests, all passing. Half C is in the verbose transcript, so it ran rather than being collected and skipped.
  • Fourteen no-build gate families re-derived from the real diff, all green, each quoted by its own verdict line rather than by an exit code: nul-bytes (OK … no raw ASCII control bytes), swallow-census-controls, dispatcher-error-vocabulary, engine-double-contract (OK — 759 pinned, 133 in the DEBT ledger, 3 exempt), where-matcher (332 matcher(s) discovered, 332 answer … correctly), cross-package-test-inputs (OK: 25 package(s) read outside themselves, all declared), test-source-alias, published-files, doc-authoring, objectql-double-limit, query-options-erasure, changeset-gate-self-tests, objectui-changeset, pm-half-states.

The family list was re-derived from the actual change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack rather than from the dispatch note: 36 families, 31 by path and 7 by change kind. The heavy remainder — the full lint farm, the workspace typecheck, and check:type-check-debt --re-measure, which needs the whole workspace closure built — is left to CI, which runs it once regardless.

Residue, recorded rather than fixed

seedFlowActionParams guards with objectName !== GLOBAL_ACTION_OBJECT_KEY, but isObjectLessActionKey — exported from the same module and already imported into this file — also treats the legacy '*' key and the empty string as object-less. A route resolved at '*' therefore reaches the branch and seeds a *Id param key. Inert in practice, since #3913 left nothing registering under '*', and correcting it would be a behaviour change rather than a spelling one, so it is deliberately not in this PR. Neither of the two functions edited here is named by any test in the repo, which is the other half of why the drift was invisible. Both observations are in the report to PM; the GitHub search quota was exhausted at the time, so the mandatory dedup search for a new card could not be run and filing blind would have been worse than handing it over.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…14678)
`GLOBAL_ACTION_OBJECT_KEY` exists so the object-less action-registration key
is written once. #14422 converged the owner-key ladder and the ObjectQL
plugin's copy of it; three bare `'global'` spellings elsewhere in
`packages/runtime/src/action-execution.ts` were never in that card's path,
because the runtime fence it built was a re-export plus a delegating alias.
All three are equal in value to the constant today — which is the defect, not
a mitigation: it is what made them invisible to every test in the repo, and
what would have let them part from the constant in silence.
- `seedFlowActionParams`: the live comparison deciding whether an
object-derived `<object>Id` param key is seeded. Substitution only — the
predicate is unchanged, so `'*'` still falls through exactly as before.
- `enforceActionParams`: the warn-once dedup key, also interpolated into the
operator-facing `[action-params] <key>: …` line.
- `collectActionDeclarations`'s docblock, which also called the key "the
wildcard" against `action-governance.ts`'s "no wildcard semantics". It now
reads as the sibling docblock 48 lines below already did.
`action-owner-key-single-source.test.ts` gains a half C that reads
`action-execution.ts` and fails if any quote spelling is written out by hand
again. The forbidden spelling is derived from the constant, so the guard is
not itself another copy of the literal it forbids.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

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

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

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33742671888 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA(日志不可读,点进 job 看)

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 73ad0bbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14678-action-execution-global-literals branch September 3, 2026 10:46
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

3 participants

@os-trump@os-project-manager@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

refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts - #14861

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals
Sep 3, 2026
Merged

refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts#14861
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14678

File face: packages/runtime/src/action-execution.ts, and nothing else.packages/runtime/src/sandbox/** is untouched — PR #14826 is armed on that face with card #14760 queued behind it. packages/spec is read-only here: it owns one site in this class and that site is named below rather than edited.

Re-measured before editing, because the card said to

$ git grep -n "'global'" origin/main -- packages/runtime/src/action-execution.ts
origin/main:packages/runtime/src/action-execution.ts:641: if (objectName && objectName !== 'global') {
origin/main:packages/runtime/src/action-execution.ts:1016: const key = `${where.objectName ?? 'global'}/${where.actionName ?? action?.name ?? 'action'}`;
origin/main:packages/runtime/src/action-execution.ts:1490: * legacy `object` field, else the `'global'` wildcard).

Three, at the three line numbers the card's current table gives. Neither of the two corrections that moved this card applies any more: the withdrawn fourth site at :1539 is gone, and the numbers have not shifted since 75adf11.

Widened past the single-quote spelling, in case the count was an artefact of the grep rather than of the tree:

$ git grep -nE "(['\"`])global\1|\bglobal\b" origin/main -- packages/runtime/src/action-execution.ts

adds only :962pass \undefined` for a global parent object schema` — the English adjective, not the token. So three is the real population, not three-of-some-quote-style.

What changed

SiteKindCall
seedFlowActionParamslive comparisonconverged
enforceActionParamswarn-once log keyconverged, argued below
collectActionDeclarations docblockprose, plus a second defectconverged

seedFlowActionParams is the one that matters — a live comparison deciding whether an object-derived Id param key is seeded into an action's bag. Pure substitution: objectName !== GLOBAL_ACTION_OBJECT_KEY. The predicate is byte-for-byte the same test, so the legacy '*' key still falls through exactly as it did (see Residue).

enforceActionParams is the judgement the card asked to be made out loud. The case for leaving a literal is that a warn-once dedup key — which is also interpolated into the operator-facing [action-params] … line — must never fail to render. That argument does not survive contact with what the alternative actually is: GLOBAL_ACTION_OBJECT_KEY is a module-scope const string, already imported into this file, already interpolated by the sibling site. It cannot fail to render either. What a drift there really costs is an operator grepping logs by the key the engine now uses and silently missing these lines. So: converged. Note the deliberate asymmetry left in place one interpolation to the right — the ?? 'action' fallback in the same template stays a bare literal, because it is a display placeholder with no constant behind it and no engine concept underneath. Same token shape, different role; that is the whole distinction this card turns on.

The docblock carried a second defect independent of the literal: it called the key "the 'global' wildcard", contradicting action-governance.ts — "engine.executeAction is an exact-string Map lookup with no wildcard semantics". Both defects close together, and the replacement is not invented: it is the phrasing the sibling docblock 48 lines below already used, so the file now says the same thing in both places.

grep -c for all three quote spellings of the token in this file is now 0, 0, 0.

The sweep, and how true instances were told from same-token noise

The token is everywhere — 626 occurrences across 160 source files. Nearly all of them are a different vocabulary that happens to share a word, and the difference is invisible from the token:

$ git grep -c "'global'" origin/main -- 'packages/**/*.ts' 'packages/**/*.tsx' \
'apps/**/*.ts' 'apps/**/*.tsx' 'examples/**/*.ts' 'scripts/**' | awk -F: '{s+=$2} END {print s, NR}'
626 160

The discriminator is not the token, it is the role: does this value become, or get compared against, the object half of the engine's objectName:actionName registration key that executeAction looks up? Applying it splits the population into one true class and five unrelated vocabularies:

  • ADR-0120 uniqueness scope (unique: 'global' | 'organization') — by far the largest bucket, and the one that would have swallowed a token-level sweep whole.
  • Settings / storage / permission scope (scope: 'global' | 'tenant' | 'user'), lifecycle and tenancy scope, automation lockScope, and app payload fields such as the showcase action's own scope: 'global' return value.

None of those five ever reaches an action registration key. That leaves the true class — every site where the value really is the object-less action key:

SiteVerdict
objectql/src/action-governance.ts:61the declaration itself — the single source, not a defect
objectql/src/action-governance.ts:87prose quoting the anti-pattern it replaced; correct as written
runtime/src/action-execution.ts ×3converged here
cli/src/commands/lint.ts:321true instance — left, see below
client/src/index.ts:4269true instance — left, see below
spec/src/stack.zod.ts:1537deliberate second spelling — left, see below
runtime/src/domains/actions.ts ×4prose only; live code already converged — left
cli/src/lint/hook-body-lowering.ts:279, cli/src/utils/lower-callables.ts:198false positives by role

What was deliberately left, and why

packages/cli/src/commands/lint.ts:321 is a genuine instance — it builds the engine registration key with the same objectName-else-literal ladder, and its own comment says so ("it is the literal the engine really registers under"). It is not converged here for a reason that is not taste: that file is claimed.#14669 is open and pm:dispatched on packages/cli/src/commands/lint.ts:298 — twenty-three lines above, inside the same docblock. Editing around another seat's live claim buys a conflict and nothing else. #14669's own body also states the precedent for the cross-package half: pulling @objectstack/cli in "would pull both into turbo's affected set and add a verification surface to a p3 convergence card".

packages/client/src/index.ts:4269 (invokeGlobal dispatching to invoke('global', …)) is a true instance that cannot be converged as things stand. @objectstack/client depends on @objectstack/core and @objectstack/spec and nothing else — it has no path to GLOBAL_ACTION_OBJECT_KEY without giving the browser SDK an engine dependency. That is an architecture decision, not a p3 cleanup.

packages/spec/src/stack.zod.ts:1537 re-declares the value as GLOBAL_ACTION_SCOPE with a docblock stating exactly why — "spelled here rather than imported because the spec sits below every runtime package". Off-limits to this seat by fence, and it is a documented second spelling rather than an accidental one. Naming it, not touching it.

packages/runtime/src/domains/actions.ts carries four quoted spellings, all in comments; its live code already resolves through actionExec.GLOBAL_ACTION_OBJECT_KEY at :431. Two of the four describe the literal URL path segment/actions/global/:action, which is a different thing that happens to share the word; the other two narrate the historical #3913 defect and are statements about a measured past. None carries the wildcard contradiction. Left, and the reason is on the record here rather than in silence.

The two packages/cli lowering sites are false positives by role, which is worth showing rather than asserting. 'global' there is an ownerLabel that becomes a generated handler name prefix (global_myAction) for the callable registry, and a diagnostic string. It never becomes an objectName. If the constant moved, those labels would produce a differently-named handler ref — consistently, within the same lowering pass. No coupling, so no defect.

Can the next one be made loud?

Yes, and cheaply enough that declining would have been the harder argument. action-owner-key-single-source.test.ts already exists in this package for exactly this weld — half A pins the behaviour, half B pins the structure. It gains a half C that reads action-execution.ts and fails if any quote spelling of the key is written by hand again.

The forbidden spelling is derived from the constant, not hard-coded:

constBARE_LITERALS: readonlystring[]=[`'${GLOBAL_ACTION_OBJECT_KEY}'`,`"${GLOBAL_ACTION_OBJECT_KEY}"`,`\`${GLOBAL_ACTION_OBJECT_KEY}\``,];

A hard-coded 'global' there would have been a fourth copy of the very literal the file exists to forbid, going stale in the same silence. Derived, the guard follows the constant wherever it goes.

This is proportionate for a p3 because it costs no new verification surface: an existing test file, in the affected package, reading a file it already reads, under gate families this card already owes.

It was verified to fail, twice, for two different reasons — a guard nobody has watched go red is a guard nobody has tested:

MutationExpectedObserved
re-inline the literal at the live comparisonredred — tripped the anti-vacuity control first
re-inline at the log key, leaving the control text intactred on the negative armred, with the intended message

Both legs proved the mutation reached disk before reading any result (injected-text and removed-text counts, plus a git hash-object comparison against the HEAD blob), restored with git checkout HEAD --, and proved the restore byte-identical (git diff HEAD empty, hash equal to the HEAD blob) before re-running green. No rebuild was needed for either leg and none is claimed: half C reads source text at runtime, and the constant it derives from comes from @objectstack/objectql's already-built dist, which no mutation here touches.

Changeset fork

patch on @objectstack/runtime, not skip-changeset.packages/runtime's files whitelist is ["dist", "README.md", "CHANGELOG.md"]src is not published raw, but dist is built from it, so the emitted output moves even though the behaviour does not. That is outside the skip-changeset criterion, which needs a diff that publishes nothing from any package. Nothing reaches the published entry either — no export, signature or type changed, and action-execution.ts is not re-exported from packages/runtime/src/index.ts — which is what keeps it below minor. This is the same fork and the same measurement PR #14667 took for this package on this file.

Readings — all at d1ec90e7, the head of this branch

Dependency closure built first (pnpm --filter '@objectstack/runtime^...' build), so nothing below is a stale-dist reading.

  • pnpm --filter @objectstack/runtime typecheck — exit 0. Run by name, so it chained the second link: 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 (shrink-only and identity-pinned). Nothing was added to that ledger. The edited test file is not in it, and tsc -p tsconfig.test.json --listFiles reaches it — so this is a measurement of the new code, not a green that skipped it.
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 action23 files, 366 tests, all passing. Half C is in the verbose transcript, so it ran rather than being collected and skipped.
  • Fourteen no-build gate families re-derived from the real diff, all green, each quoted by its own verdict line rather than by an exit code: nul-bytes (OK … no raw ASCII control bytes), swallow-census-controls, dispatcher-error-vocabulary, engine-double-contract (OK — 759 pinned, 133 in the DEBT ledger, 3 exempt), where-matcher (332 matcher(s) discovered, 332 answer … correctly), cross-package-test-inputs (OK: 25 package(s) read outside themselves, all declared), test-source-alias, published-files, doc-authoring, objectql-double-limit, query-options-erasure, changeset-gate-self-tests, objectui-changeset, pm-half-states.

The family list was re-derived from the actual change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack rather than from the dispatch note: 36 families, 31 by path and 7 by change kind. The heavy remainder — the full lint farm, the workspace typecheck, and check:type-check-debt --re-measure, which needs the whole workspace closure built — is left to CI, which runs it once regardless.

Residue, recorded rather than fixed

seedFlowActionParams guards with objectName !== GLOBAL_ACTION_OBJECT_KEY, but isObjectLessActionKey — exported from the same module and already imported into this file — also treats the legacy '*' key and the empty string as object-less. A route resolved at '*' therefore reaches the branch and seeds a *Id param key. Inert in practice, since #3913 left nothing registering under '*', and correcting it would be a behaviour change rather than a spelling one, so it is deliberately not in this PR. Neither of the two functions edited here is named by any test in the repo, which is the other half of why the drift was invisible. Both observations are in the report to PM; the GitHub search quota was exhausted at the time, so the mandatory dedup search for a new card could not be run and filing blind would have been worse than handing it over.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…14678)
`GLOBAL_ACTION_OBJECT_KEY` exists so the object-less action-registration key
is written once. #14422 converged the owner-key ladder and the ObjectQL
plugin's copy of it; three bare `'global'` spellings elsewhere in
`packages/runtime/src/action-execution.ts` were never in that card's path,
because the runtime fence it built was a re-export plus a delegating alias.
All three are equal in value to the constant today — which is the defect, not
a mitigation: it is what made them invisible to every test in the repo, and
what would have let them part from the constant in silence.
- `seedFlowActionParams`: the live comparison deciding whether an
object-derived `<object>Id` param key is seeded. Substitution only — the
predicate is unchanged, so `'*'` still falls through exactly as before.
- `enforceActionParams`: the warn-once dedup key, also interpolated into the
operator-facing `[action-params] <key>: …` line.
- `collectActionDeclarations`'s docblock, which also called the key "the
wildcard" against `action-governance.ts`'s "no wildcard semantics". It now
reads as the sibling docblock 48 lines below already did.
`action-owner-key-single-source.test.ts` gains a half C that reads
`action-execution.ts` and fails if any quote spelling is written out by hand
again. The forbidden spelling is derived from the constant, so the guard is
not itself another copy of the literal it forbids.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

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

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

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33742671888 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA(日志不可读,点进 job 看)

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 73ad0bbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14678-action-execution-global-literals branch September 3, 2026 10:46
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

3 participants

@os-trump@os-project-manager@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

refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts - #14861

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals
Sep 3, 2026
Merged

refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts#14861
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14678

File face: packages/runtime/src/action-execution.ts, and nothing else.packages/runtime/src/sandbox/** is untouched — PR #14826 is armed on that face with card #14760 queued behind it. packages/spec is read-only here: it owns one site in this class and that site is named below rather than edited.

Re-measured before editing, because the card said to

$ git grep -n "'global'" origin/main -- packages/runtime/src/action-execution.ts
origin/main:packages/runtime/src/action-execution.ts:641: if (objectName && objectName !== 'global') {
origin/main:packages/runtime/src/action-execution.ts:1016: const key = `${where.objectName ?? 'global'}/${where.actionName ?? action?.name ?? 'action'}`;
origin/main:packages/runtime/src/action-execution.ts:1490: * legacy `object` field, else the `'global'` wildcard).

Three, at the three line numbers the card's current table gives. Neither of the two corrections that moved this card applies any more: the withdrawn fourth site at :1539 is gone, and the numbers have not shifted since 75adf11.

Widened past the single-quote spelling, in case the count was an artefact of the grep rather than of the tree:

$ git grep -nE "(['\"`])global\1|\bglobal\b" origin/main -- packages/runtime/src/action-execution.ts

adds only :962pass \undefined` for a global parent object schema` — the English adjective, not the token. So three is the real population, not three-of-some-quote-style.

What changed

SiteKindCall
seedFlowActionParamslive comparisonconverged
enforceActionParamswarn-once log keyconverged, argued below
collectActionDeclarations docblockprose, plus a second defectconverged

seedFlowActionParams is the one that matters — a live comparison deciding whether an object-derived Id param key is seeded into an action's bag. Pure substitution: objectName !== GLOBAL_ACTION_OBJECT_KEY. The predicate is byte-for-byte the same test, so the legacy '*' key still falls through exactly as it did (see Residue).

enforceActionParams is the judgement the card asked to be made out loud. The case for leaving a literal is that a warn-once dedup key — which is also interpolated into the operator-facing [action-params] … line — must never fail to render. That argument does not survive contact with what the alternative actually is: GLOBAL_ACTION_OBJECT_KEY is a module-scope const string, already imported into this file, already interpolated by the sibling site. It cannot fail to render either. What a drift there really costs is an operator grepping logs by the key the engine now uses and silently missing these lines. So: converged. Note the deliberate asymmetry left in place one interpolation to the right — the ?? 'action' fallback in the same template stays a bare literal, because it is a display placeholder with no constant behind it and no engine concept underneath. Same token shape, different role; that is the whole distinction this card turns on.

The docblock carried a second defect independent of the literal: it called the key "the 'global' wildcard", contradicting action-governance.ts — "engine.executeAction is an exact-string Map lookup with no wildcard semantics". Both defects close together, and the replacement is not invented: it is the phrasing the sibling docblock 48 lines below already used, so the file now says the same thing in both places.

grep -c for all three quote spellings of the token in this file is now 0, 0, 0.

The sweep, and how true instances were told from same-token noise

The token is everywhere — 626 occurrences across 160 source files. Nearly all of them are a different vocabulary that happens to share a word, and the difference is invisible from the token:

$ git grep -c "'global'" origin/main -- 'packages/**/*.ts' 'packages/**/*.tsx' \
'apps/**/*.ts' 'apps/**/*.tsx' 'examples/**/*.ts' 'scripts/**' | awk -F: '{s+=$2} END {print s, NR}'
626 160

The discriminator is not the token, it is the role: does this value become, or get compared against, the object half of the engine's objectName:actionName registration key that executeAction looks up? Applying it splits the population into one true class and five unrelated vocabularies:

  • ADR-0120 uniqueness scope (unique: 'global' | 'organization') — by far the largest bucket, and the one that would have swallowed a token-level sweep whole.
  • Settings / storage / permission scope (scope: 'global' | 'tenant' | 'user'), lifecycle and tenancy scope, automation lockScope, and app payload fields such as the showcase action's own scope: 'global' return value.

None of those five ever reaches an action registration key. That leaves the true class — every site where the value really is the object-less action key:

SiteVerdict
objectql/src/action-governance.ts:61the declaration itself — the single source, not a defect
objectql/src/action-governance.ts:87prose quoting the anti-pattern it replaced; correct as written
runtime/src/action-execution.ts ×3converged here
cli/src/commands/lint.ts:321true instance — left, see below
client/src/index.ts:4269true instance — left, see below
spec/src/stack.zod.ts:1537deliberate second spelling — left, see below
runtime/src/domains/actions.ts ×4prose only; live code already converged — left
cli/src/lint/hook-body-lowering.ts:279, cli/src/utils/lower-callables.ts:198false positives by role

What was deliberately left, and why

packages/cli/src/commands/lint.ts:321 is a genuine instance — it builds the engine registration key with the same objectName-else-literal ladder, and its own comment says so ("it is the literal the engine really registers under"). It is not converged here for a reason that is not taste: that file is claimed.#14669 is open and pm:dispatched on packages/cli/src/commands/lint.ts:298 — twenty-three lines above, inside the same docblock. Editing around another seat's live claim buys a conflict and nothing else. #14669's own body also states the precedent for the cross-package half: pulling @objectstack/cli in "would pull both into turbo's affected set and add a verification surface to a p3 convergence card".

packages/client/src/index.ts:4269 (invokeGlobal dispatching to invoke('global', …)) is a true instance that cannot be converged as things stand. @objectstack/client depends on @objectstack/core and @objectstack/spec and nothing else — it has no path to GLOBAL_ACTION_OBJECT_KEY without giving the browser SDK an engine dependency. That is an architecture decision, not a p3 cleanup.

packages/spec/src/stack.zod.ts:1537 re-declares the value as GLOBAL_ACTION_SCOPE with a docblock stating exactly why — "spelled here rather than imported because the spec sits below every runtime package". Off-limits to this seat by fence, and it is a documented second spelling rather than an accidental one. Naming it, not touching it.

packages/runtime/src/domains/actions.ts carries four quoted spellings, all in comments; its live code already resolves through actionExec.GLOBAL_ACTION_OBJECT_KEY at :431. Two of the four describe the literal URL path segment/actions/global/:action, which is a different thing that happens to share the word; the other two narrate the historical #3913 defect and are statements about a measured past. None carries the wildcard contradiction. Left, and the reason is on the record here rather than in silence.

The two packages/cli lowering sites are false positives by role, which is worth showing rather than asserting. 'global' there is an ownerLabel that becomes a generated handler name prefix (global_myAction) for the callable registry, and a diagnostic string. It never becomes an objectName. If the constant moved, those labels would produce a differently-named handler ref — consistently, within the same lowering pass. No coupling, so no defect.

Can the next one be made loud?

Yes, and cheaply enough that declining would have been the harder argument. action-owner-key-single-source.test.ts already exists in this package for exactly this weld — half A pins the behaviour, half B pins the structure. It gains a half C that reads action-execution.ts and fails if any quote spelling of the key is written by hand again.

The forbidden spelling is derived from the constant, not hard-coded:

constBARE_LITERALS: readonlystring[]=[`'${GLOBAL_ACTION_OBJECT_KEY}'`,`"${GLOBAL_ACTION_OBJECT_KEY}"`,`\`${GLOBAL_ACTION_OBJECT_KEY}\``,];

A hard-coded 'global' there would have been a fourth copy of the very literal the file exists to forbid, going stale in the same silence. Derived, the guard follows the constant wherever it goes.

This is proportionate for a p3 because it costs no new verification surface: an existing test file, in the affected package, reading a file it already reads, under gate families this card already owes.

It was verified to fail, twice, for two different reasons — a guard nobody has watched go red is a guard nobody has tested:

MutationExpectedObserved
re-inline the literal at the live comparisonredred — tripped the anti-vacuity control first
re-inline at the log key, leaving the control text intactred on the negative armred, with the intended message

Both legs proved the mutation reached disk before reading any result (injected-text and removed-text counts, plus a git hash-object comparison against the HEAD blob), restored with git checkout HEAD --, and proved the restore byte-identical (git diff HEAD empty, hash equal to the HEAD blob) before re-running green. No rebuild was needed for either leg and none is claimed: half C reads source text at runtime, and the constant it derives from comes from @objectstack/objectql's already-built dist, which no mutation here touches.

Changeset fork

patch on @objectstack/runtime, not skip-changeset.packages/runtime's files whitelist is ["dist", "README.md", "CHANGELOG.md"]src is not published raw, but dist is built from it, so the emitted output moves even though the behaviour does not. That is outside the skip-changeset criterion, which needs a diff that publishes nothing from any package. Nothing reaches the published entry either — no export, signature or type changed, and action-execution.ts is not re-exported from packages/runtime/src/index.ts — which is what keeps it below minor. This is the same fork and the same measurement PR #14667 took for this package on this file.

Readings — all at d1ec90e7, the head of this branch

Dependency closure built first (pnpm --filter '@objectstack/runtime^...' build), so nothing below is a stale-dist reading.

  • pnpm --filter @objectstack/runtime typecheck — exit 0. Run by name, so it chained the second link: 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 (shrink-only and identity-pinned). Nothing was added to that ledger. The edited test file is not in it, and tsc -p tsconfig.test.json --listFiles reaches it — so this is a measurement of the new code, not a green that skipped it.
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 action23 files, 366 tests, all passing. Half C is in the verbose transcript, so it ran rather than being collected and skipped.
  • Fourteen no-build gate families re-derived from the real diff, all green, each quoted by its own verdict line rather than by an exit code: nul-bytes (OK … no raw ASCII control bytes), swallow-census-controls, dispatcher-error-vocabulary, engine-double-contract (OK — 759 pinned, 133 in the DEBT ledger, 3 exempt), where-matcher (332 matcher(s) discovered, 332 answer … correctly), cross-package-test-inputs (OK: 25 package(s) read outside themselves, all declared), test-source-alias, published-files, doc-authoring, objectql-double-limit, query-options-erasure, changeset-gate-self-tests, objectui-changeset, pm-half-states.

The family list was re-derived from the actual change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack rather than from the dispatch note: 36 families, 31 by path and 7 by change kind. The heavy remainder — the full lint farm, the workspace typecheck, and check:type-check-debt --re-measure, which needs the whole workspace closure built — is left to CI, which runs it once regardless.

Residue, recorded rather than fixed

seedFlowActionParams guards with objectName !== GLOBAL_ACTION_OBJECT_KEY, but isObjectLessActionKey — exported from the same module and already imported into this file — also treats the legacy '*' key and the empty string as object-less. A route resolved at '*' therefore reaches the branch and seeds a *Id param key. Inert in practice, since #3913 left nothing registering under '*', and correcting it would be a behaviour change rather than a spelling one, so it is deliberately not in this PR. Neither of the two functions edited here is named by any test in the repo, which is the other half of why the drift was invisible. Both observations are in the report to PM; the GitHub search quota was exhausted at the time, so the mandatory dedup search for a new card could not be run and filing blind would have been worse than handing it over.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…14678)
`GLOBAL_ACTION_OBJECT_KEY` exists so the object-less action-registration key
is written once. #14422 converged the owner-key ladder and the ObjectQL
plugin's copy of it; three bare `'global'` spellings elsewhere in
`packages/runtime/src/action-execution.ts` were never in that card's path,
because the runtime fence it built was a re-export plus a delegating alias.
All three are equal in value to the constant today — which is the defect, not
a mitigation: it is what made them invisible to every test in the repo, and
what would have let them part from the constant in silence.
- `seedFlowActionParams`: the live comparison deciding whether an
object-derived `<object>Id` param key is seeded. Substitution only — the
predicate is unchanged, so `'*'` still falls through exactly as before.
- `enforceActionParams`: the warn-once dedup key, also interpolated into the
operator-facing `[action-params] <key>: …` line.
- `collectActionDeclarations`'s docblock, which also called the key "the
wildcard" against `action-governance.ts`'s "no wildcard semantics". It now
reads as the sibling docblock 48 lines below already did.
`action-owner-key-single-source.test.ts` gains a half C that reads
`action-execution.ts` and fails if any quote spelling is written out by hand
again. The forbidden spelling is derived from the constant, so the guard is
not itself another copy of the literal it forbids.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

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

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

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33742671888 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA(日志不可读,点进 job 看)

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 73ad0bbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14678-action-execution-global-literals branch September 3, 2026 10:46
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

3 participants

@os-trump@os-project-manager@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

refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts - #14861

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals
Sep 3, 2026
Merged

refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts#14861
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14678

File face: packages/runtime/src/action-execution.ts, and nothing else.packages/runtime/src/sandbox/** is untouched — PR #14826 is armed on that face with card #14760 queued behind it. packages/spec is read-only here: it owns one site in this class and that site is named below rather than edited.

Re-measured before editing, because the card said to

$ git grep -n "'global'" origin/main -- packages/runtime/src/action-execution.ts
origin/main:packages/runtime/src/action-execution.ts:641: if (objectName && objectName !== 'global') {
origin/main:packages/runtime/src/action-execution.ts:1016: const key = `${where.objectName ?? 'global'}/${where.actionName ?? action?.name ?? 'action'}`;
origin/main:packages/runtime/src/action-execution.ts:1490: * legacy `object` field, else the `'global'` wildcard).

Three, at the three line numbers the card's current table gives. Neither of the two corrections that moved this card applies any more: the withdrawn fourth site at :1539 is gone, and the numbers have not shifted since 75adf11.

Widened past the single-quote spelling, in case the count was an artefact of the grep rather than of the tree:

$ git grep -nE "(['\"`])global\1|\bglobal\b" origin/main -- packages/runtime/src/action-execution.ts

adds only :962pass \undefined` for a global parent object schema` — the English adjective, not the token. So three is the real population, not three-of-some-quote-style.

What changed

SiteKindCall
seedFlowActionParamslive comparisonconverged
enforceActionParamswarn-once log keyconverged, argued below
collectActionDeclarations docblockprose, plus a second defectconverged

seedFlowActionParams is the one that matters — a live comparison deciding whether an object-derived Id param key is seeded into an action's bag. Pure substitution: objectName !== GLOBAL_ACTION_OBJECT_KEY. The predicate is byte-for-byte the same test, so the legacy '*' key still falls through exactly as it did (see Residue).

enforceActionParams is the judgement the card asked to be made out loud. The case for leaving a literal is that a warn-once dedup key — which is also interpolated into the operator-facing [action-params] … line — must never fail to render. That argument does not survive contact with what the alternative actually is: GLOBAL_ACTION_OBJECT_KEY is a module-scope const string, already imported into this file, already interpolated by the sibling site. It cannot fail to render either. What a drift there really costs is an operator grepping logs by the key the engine now uses and silently missing these lines. So: converged. Note the deliberate asymmetry left in place one interpolation to the right — the ?? 'action' fallback in the same template stays a bare literal, because it is a display placeholder with no constant behind it and no engine concept underneath. Same token shape, different role; that is the whole distinction this card turns on.

The docblock carried a second defect independent of the literal: it called the key "the 'global' wildcard", contradicting action-governance.ts — "engine.executeAction is an exact-string Map lookup with no wildcard semantics". Both defects close together, and the replacement is not invented: it is the phrasing the sibling docblock 48 lines below already used, so the file now says the same thing in both places.

grep -c for all three quote spellings of the token in this file is now 0, 0, 0.

The sweep, and how true instances were told from same-token noise

The token is everywhere — 626 occurrences across 160 source files. Nearly all of them are a different vocabulary that happens to share a word, and the difference is invisible from the token:

$ git grep -c "'global'" origin/main -- 'packages/**/*.ts' 'packages/**/*.tsx' \
'apps/**/*.ts' 'apps/**/*.tsx' 'examples/**/*.ts' 'scripts/**' | awk -F: '{s+=$2} END {print s, NR}'
626 160

The discriminator is not the token, it is the role: does this value become, or get compared against, the object half of the engine's objectName:actionName registration key that executeAction looks up? Applying it splits the population into one true class and five unrelated vocabularies:

  • ADR-0120 uniqueness scope (unique: 'global' | 'organization') — by far the largest bucket, and the one that would have swallowed a token-level sweep whole.
  • Settings / storage / permission scope (scope: 'global' | 'tenant' | 'user'), lifecycle and tenancy scope, automation lockScope, and app payload fields such as the showcase action's own scope: 'global' return value.

None of those five ever reaches an action registration key. That leaves the true class — every site where the value really is the object-less action key:

SiteVerdict
objectql/src/action-governance.ts:61the declaration itself — the single source, not a defect
objectql/src/action-governance.ts:87prose quoting the anti-pattern it replaced; correct as written
runtime/src/action-execution.ts ×3converged here
cli/src/commands/lint.ts:321true instance — left, see below
client/src/index.ts:4269true instance — left, see below
spec/src/stack.zod.ts:1537deliberate second spelling — left, see below
runtime/src/domains/actions.ts ×4prose only; live code already converged — left
cli/src/lint/hook-body-lowering.ts:279, cli/src/utils/lower-callables.ts:198false positives by role

What was deliberately left, and why

packages/cli/src/commands/lint.ts:321 is a genuine instance — it builds the engine registration key with the same objectName-else-literal ladder, and its own comment says so ("it is the literal the engine really registers under"). It is not converged here for a reason that is not taste: that file is claimed.#14669 is open and pm:dispatched on packages/cli/src/commands/lint.ts:298 — twenty-three lines above, inside the same docblock. Editing around another seat's live claim buys a conflict and nothing else. #14669's own body also states the precedent for the cross-package half: pulling @objectstack/cli in "would pull both into turbo's affected set and add a verification surface to a p3 convergence card".

packages/client/src/index.ts:4269 (invokeGlobal dispatching to invoke('global', …)) is a true instance that cannot be converged as things stand. @objectstack/client depends on @objectstack/core and @objectstack/spec and nothing else — it has no path to GLOBAL_ACTION_OBJECT_KEY without giving the browser SDK an engine dependency. That is an architecture decision, not a p3 cleanup.

packages/spec/src/stack.zod.ts:1537 re-declares the value as GLOBAL_ACTION_SCOPE with a docblock stating exactly why — "spelled here rather than imported because the spec sits below every runtime package". Off-limits to this seat by fence, and it is a documented second spelling rather than an accidental one. Naming it, not touching it.

packages/runtime/src/domains/actions.ts carries four quoted spellings, all in comments; its live code already resolves through actionExec.GLOBAL_ACTION_OBJECT_KEY at :431. Two of the four describe the literal URL path segment/actions/global/:action, which is a different thing that happens to share the word; the other two narrate the historical #3913 defect and are statements about a measured past. None carries the wildcard contradiction. Left, and the reason is on the record here rather than in silence.

The two packages/cli lowering sites are false positives by role, which is worth showing rather than asserting. 'global' there is an ownerLabel that becomes a generated handler name prefix (global_myAction) for the callable registry, and a diagnostic string. It never becomes an objectName. If the constant moved, those labels would produce a differently-named handler ref — consistently, within the same lowering pass. No coupling, so no defect.

Can the next one be made loud?

Yes, and cheaply enough that declining would have been the harder argument. action-owner-key-single-source.test.ts already exists in this package for exactly this weld — half A pins the behaviour, half B pins the structure. It gains a half C that reads action-execution.ts and fails if any quote spelling of the key is written by hand again.

The forbidden spelling is derived from the constant, not hard-coded:

constBARE_LITERALS: readonlystring[]=[`'${GLOBAL_ACTION_OBJECT_KEY}'`,`"${GLOBAL_ACTION_OBJECT_KEY}"`,`\`${GLOBAL_ACTION_OBJECT_KEY}\``,];

A hard-coded 'global' there would have been a fourth copy of the very literal the file exists to forbid, going stale in the same silence. Derived, the guard follows the constant wherever it goes.

This is proportionate for a p3 because it costs no new verification surface: an existing test file, in the affected package, reading a file it already reads, under gate families this card already owes.

It was verified to fail, twice, for two different reasons — a guard nobody has watched go red is a guard nobody has tested:

MutationExpectedObserved
re-inline the literal at the live comparisonredred — tripped the anti-vacuity control first
re-inline at the log key, leaving the control text intactred on the negative armred, with the intended message

Both legs proved the mutation reached disk before reading any result (injected-text and removed-text counts, plus a git hash-object comparison against the HEAD blob), restored with git checkout HEAD --, and proved the restore byte-identical (git diff HEAD empty, hash equal to the HEAD blob) before re-running green. No rebuild was needed for either leg and none is claimed: half C reads source text at runtime, and the constant it derives from comes from @objectstack/objectql's already-built dist, which no mutation here touches.

Changeset fork

patch on @objectstack/runtime, not skip-changeset.packages/runtime's files whitelist is ["dist", "README.md", "CHANGELOG.md"]src is not published raw, but dist is built from it, so the emitted output moves even though the behaviour does not. That is outside the skip-changeset criterion, which needs a diff that publishes nothing from any package. Nothing reaches the published entry either — no export, signature or type changed, and action-execution.ts is not re-exported from packages/runtime/src/index.ts — which is what keeps it below minor. This is the same fork and the same measurement PR #14667 took for this package on this file.

Readings — all at d1ec90e7, the head of this branch

Dependency closure built first (pnpm --filter '@objectstack/runtime^...' build), so nothing below is a stale-dist reading.

  • pnpm --filter @objectstack/runtime typecheck — exit 0. Run by name, so it chained the second link: 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 (shrink-only and identity-pinned). Nothing was added to that ledger. The edited test file is not in it, and tsc -p tsconfig.test.json --listFiles reaches it — so this is a measurement of the new code, not a green that skipped it.
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 action23 files, 366 tests, all passing. Half C is in the verbose transcript, so it ran rather than being collected and skipped.
  • Fourteen no-build gate families re-derived from the real diff, all green, each quoted by its own verdict line rather than by an exit code: nul-bytes (OK … no raw ASCII control bytes), swallow-census-controls, dispatcher-error-vocabulary, engine-double-contract (OK — 759 pinned, 133 in the DEBT ledger, 3 exempt), where-matcher (332 matcher(s) discovered, 332 answer … correctly), cross-package-test-inputs (OK: 25 package(s) read outside themselves, all declared), test-source-alias, published-files, doc-authoring, objectql-double-limit, query-options-erasure, changeset-gate-self-tests, objectui-changeset, pm-half-states.

The family list was re-derived from the actual change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack rather than from the dispatch note: 36 families, 31 by path and 7 by change kind. The heavy remainder — the full lint farm, the workspace typecheck, and check:type-check-debt --re-measure, which needs the whole workspace closure built — is left to CI, which runs it once regardless.

Residue, recorded rather than fixed

seedFlowActionParams guards with objectName !== GLOBAL_ACTION_OBJECT_KEY, but isObjectLessActionKey — exported from the same module and already imported into this file — also treats the legacy '*' key and the empty string as object-less. A route resolved at '*' therefore reaches the branch and seeds a *Id param key. Inert in practice, since #3913 left nothing registering under '*', and correcting it would be a behaviour change rather than a spelling one, so it is deliberately not in this PR. Neither of the two functions edited here is named by any test in the repo, which is the other half of why the drift was invisible. Both observations are in the report to PM; the GitHub search quota was exhausted at the time, so the mandatory dedup search for a new card could not be run and filing blind would have been worse than handing it over.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…14678)
`GLOBAL_ACTION_OBJECT_KEY` exists so the object-less action-registration key
is written once. #14422 converged the owner-key ladder and the ObjectQL
plugin's copy of it; three bare `'global'` spellings elsewhere in
`packages/runtime/src/action-execution.ts` were never in that card's path,
because the runtime fence it built was a re-export plus a delegating alias.
All three are equal in value to the constant today — which is the defect, not
a mitigation: it is what made them invisible to every test in the repo, and
what would have let them part from the constant in silence.
- `seedFlowActionParams`: the live comparison deciding whether an
object-derived `<object>Id` param key is seeded. Substitution only — the
predicate is unchanged, so `'*'` still falls through exactly as before.
- `enforceActionParams`: the warn-once dedup key, also interpolated into the
operator-facing `[action-params] <key>: …` line.
- `collectActionDeclarations`'s docblock, which also called the key "the
wildcard" against `action-governance.ts`'s "no wildcard semantics". It now
reads as the sibling docblock 48 lines below already did.
`action-owner-key-single-source.test.ts` gains a half C that reads
`action-execution.ts` and fails if any quote spelling is written out by hand
again. The forbidden spelling is derived from the constant, so the guard is
not itself another copy of the literal it forbids.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

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

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

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33742671888 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA(日志不可读,点进 job 看)

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 73ad0bbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14678-action-execution-global-literals branch September 3, 2026 10:46
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

3 participants

@os-trump@os-project-manager@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

refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts - #14861

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals
Sep 3, 2026
Merged

refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts#14861
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14678

File face: packages/runtime/src/action-execution.ts, and nothing else.packages/runtime/src/sandbox/** is untouched — PR #14826 is armed on that face with card #14760 queued behind it. packages/spec is read-only here: it owns one site in this class and that site is named below rather than edited.

Re-measured before editing, because the card said to

$ git grep -n "'global'" origin/main -- packages/runtime/src/action-execution.ts
origin/main:packages/runtime/src/action-execution.ts:641: if (objectName && objectName !== 'global') {
origin/main:packages/runtime/src/action-execution.ts:1016: const key = `${where.objectName ?? 'global'}/${where.actionName ?? action?.name ?? 'action'}`;
origin/main:packages/runtime/src/action-execution.ts:1490: * legacy `object` field, else the `'global'` wildcard).

Three, at the three line numbers the card's current table gives. Neither of the two corrections that moved this card applies any more: the withdrawn fourth site at :1539 is gone, and the numbers have not shifted since 75adf11.

Widened past the single-quote spelling, in case the count was an artefact of the grep rather than of the tree:

$ git grep -nE "(['\"`])global\1|\bglobal\b" origin/main -- packages/runtime/src/action-execution.ts

adds only :962pass \undefined` for a global parent object schema` — the English adjective, not the token. So three is the real population, not three-of-some-quote-style.

What changed

SiteKindCall
seedFlowActionParamslive comparisonconverged
enforceActionParamswarn-once log keyconverged, argued below
collectActionDeclarations docblockprose, plus a second defectconverged

seedFlowActionParams is the one that matters — a live comparison deciding whether an object-derived Id param key is seeded into an action's bag. Pure substitution: objectName !== GLOBAL_ACTION_OBJECT_KEY. The predicate is byte-for-byte the same test, so the legacy '*' key still falls through exactly as it did (see Residue).

enforceActionParams is the judgement the card asked to be made out loud. The case for leaving a literal is that a warn-once dedup key — which is also interpolated into the operator-facing [action-params] … line — must never fail to render. That argument does not survive contact with what the alternative actually is: GLOBAL_ACTION_OBJECT_KEY is a module-scope const string, already imported into this file, already interpolated by the sibling site. It cannot fail to render either. What a drift there really costs is an operator grepping logs by the key the engine now uses and silently missing these lines. So: converged. Note the deliberate asymmetry left in place one interpolation to the right — the ?? 'action' fallback in the same template stays a bare literal, because it is a display placeholder with no constant behind it and no engine concept underneath. Same token shape, different role; that is the whole distinction this card turns on.

The docblock carried a second defect independent of the literal: it called the key "the 'global' wildcard", contradicting action-governance.ts — "engine.executeAction is an exact-string Map lookup with no wildcard semantics". Both defects close together, and the replacement is not invented: it is the phrasing the sibling docblock 48 lines below already used, so the file now says the same thing in both places.

grep -c for all three quote spellings of the token in this file is now 0, 0, 0.

The sweep, and how true instances were told from same-token noise

The token is everywhere — 626 occurrences across 160 source files. Nearly all of them are a different vocabulary that happens to share a word, and the difference is invisible from the token:

$ git grep -c "'global'" origin/main -- 'packages/**/*.ts' 'packages/**/*.tsx' \
'apps/**/*.ts' 'apps/**/*.tsx' 'examples/**/*.ts' 'scripts/**' | awk -F: '{s+=$2} END {print s, NR}'
626 160

The discriminator is not the token, it is the role: does this value become, or get compared against, the object half of the engine's objectName:actionName registration key that executeAction looks up? Applying it splits the population into one true class and five unrelated vocabularies:

  • ADR-0120 uniqueness scope (unique: 'global' | 'organization') — by far the largest bucket, and the one that would have swallowed a token-level sweep whole.
  • Settings / storage / permission scope (scope: 'global' | 'tenant' | 'user'), lifecycle and tenancy scope, automation lockScope, and app payload fields such as the showcase action's own scope: 'global' return value.

None of those five ever reaches an action registration key. That leaves the true class — every site where the value really is the object-less action key:

SiteVerdict
objectql/src/action-governance.ts:61the declaration itself — the single source, not a defect
objectql/src/action-governance.ts:87prose quoting the anti-pattern it replaced; correct as written
runtime/src/action-execution.ts ×3converged here
cli/src/commands/lint.ts:321true instance — left, see below
client/src/index.ts:4269true instance — left, see below
spec/src/stack.zod.ts:1537deliberate second spelling — left, see below
runtime/src/domains/actions.ts ×4prose only; live code already converged — left
cli/src/lint/hook-body-lowering.ts:279, cli/src/utils/lower-callables.ts:198false positives by role

What was deliberately left, and why

packages/cli/src/commands/lint.ts:321 is a genuine instance — it builds the engine registration key with the same objectName-else-literal ladder, and its own comment says so ("it is the literal the engine really registers under"). It is not converged here for a reason that is not taste: that file is claimed.#14669 is open and pm:dispatched on packages/cli/src/commands/lint.ts:298 — twenty-three lines above, inside the same docblock. Editing around another seat's live claim buys a conflict and nothing else. #14669's own body also states the precedent for the cross-package half: pulling @objectstack/cli in "would pull both into turbo's affected set and add a verification surface to a p3 convergence card".

packages/client/src/index.ts:4269 (invokeGlobal dispatching to invoke('global', …)) is a true instance that cannot be converged as things stand. @objectstack/client depends on @objectstack/core and @objectstack/spec and nothing else — it has no path to GLOBAL_ACTION_OBJECT_KEY without giving the browser SDK an engine dependency. That is an architecture decision, not a p3 cleanup.

packages/spec/src/stack.zod.ts:1537 re-declares the value as GLOBAL_ACTION_SCOPE with a docblock stating exactly why — "spelled here rather than imported because the spec sits below every runtime package". Off-limits to this seat by fence, and it is a documented second spelling rather than an accidental one. Naming it, not touching it.

packages/runtime/src/domains/actions.ts carries four quoted spellings, all in comments; its live code already resolves through actionExec.GLOBAL_ACTION_OBJECT_KEY at :431. Two of the four describe the literal URL path segment/actions/global/:action, which is a different thing that happens to share the word; the other two narrate the historical #3913 defect and are statements about a measured past. None carries the wildcard contradiction. Left, and the reason is on the record here rather than in silence.

The two packages/cli lowering sites are false positives by role, which is worth showing rather than asserting. 'global' there is an ownerLabel that becomes a generated handler name prefix (global_myAction) for the callable registry, and a diagnostic string. It never becomes an objectName. If the constant moved, those labels would produce a differently-named handler ref — consistently, within the same lowering pass. No coupling, so no defect.

Can the next one be made loud?

Yes, and cheaply enough that declining would have been the harder argument. action-owner-key-single-source.test.ts already exists in this package for exactly this weld — half A pins the behaviour, half B pins the structure. It gains a half C that reads action-execution.ts and fails if any quote spelling of the key is written by hand again.

The forbidden spelling is derived from the constant, not hard-coded:

constBARE_LITERALS: readonlystring[]=[`'${GLOBAL_ACTION_OBJECT_KEY}'`,`"${GLOBAL_ACTION_OBJECT_KEY}"`,`\`${GLOBAL_ACTION_OBJECT_KEY}\``,];

A hard-coded 'global' there would have been a fourth copy of the very literal the file exists to forbid, going stale in the same silence. Derived, the guard follows the constant wherever it goes.

This is proportionate for a p3 because it costs no new verification surface: an existing test file, in the affected package, reading a file it already reads, under gate families this card already owes.

It was verified to fail, twice, for two different reasons — a guard nobody has watched go red is a guard nobody has tested:

MutationExpectedObserved
re-inline the literal at the live comparisonredred — tripped the anti-vacuity control first
re-inline at the log key, leaving the control text intactred on the negative armred, with the intended message

Both legs proved the mutation reached disk before reading any result (injected-text and removed-text counts, plus a git hash-object comparison against the HEAD blob), restored with git checkout HEAD --, and proved the restore byte-identical (git diff HEAD empty, hash equal to the HEAD blob) before re-running green. No rebuild was needed for either leg and none is claimed: half C reads source text at runtime, and the constant it derives from comes from @objectstack/objectql's already-built dist, which no mutation here touches.

Changeset fork

patch on @objectstack/runtime, not skip-changeset.packages/runtime's files whitelist is ["dist", "README.md", "CHANGELOG.md"]src is not published raw, but dist is built from it, so the emitted output moves even though the behaviour does not. That is outside the skip-changeset criterion, which needs a diff that publishes nothing from any package. Nothing reaches the published entry either — no export, signature or type changed, and action-execution.ts is not re-exported from packages/runtime/src/index.ts — which is what keeps it below minor. This is the same fork and the same measurement PR #14667 took for this package on this file.

Readings — all at d1ec90e7, the head of this branch

Dependency closure built first (pnpm --filter '@objectstack/runtime^...' build), so nothing below is a stale-dist reading.

  • pnpm --filter @objectstack/runtime typecheck — exit 0. Run by name, so it chained the second link: 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 (shrink-only and identity-pinned). Nothing was added to that ledger. The edited test file is not in it, and tsc -p tsconfig.test.json --listFiles reaches it — so this is a measurement of the new code, not a green that skipped it.
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 action23 files, 366 tests, all passing. Half C is in the verbose transcript, so it ran rather than being collected and skipped.
  • Fourteen no-build gate families re-derived from the real diff, all green, each quoted by its own verdict line rather than by an exit code: nul-bytes (OK … no raw ASCII control bytes), swallow-census-controls, dispatcher-error-vocabulary, engine-double-contract (OK — 759 pinned, 133 in the DEBT ledger, 3 exempt), where-matcher (332 matcher(s) discovered, 332 answer … correctly), cross-package-test-inputs (OK: 25 package(s) read outside themselves, all declared), test-source-alias, published-files, doc-authoring, objectql-double-limit, query-options-erasure, changeset-gate-self-tests, objectui-changeset, pm-half-states.

The family list was re-derived from the actual change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack rather than from the dispatch note: 36 families, 31 by path and 7 by change kind. The heavy remainder — the full lint farm, the workspace typecheck, and check:type-check-debt --re-measure, which needs the whole workspace closure built — is left to CI, which runs it once regardless.

Residue, recorded rather than fixed

seedFlowActionParams guards with objectName !== GLOBAL_ACTION_OBJECT_KEY, but isObjectLessActionKey — exported from the same module and already imported into this file — also treats the legacy '*' key and the empty string as object-less. A route resolved at '*' therefore reaches the branch and seeds a *Id param key. Inert in practice, since #3913 left nothing registering under '*', and correcting it would be a behaviour change rather than a spelling one, so it is deliberately not in this PR. Neither of the two functions edited here is named by any test in the repo, which is the other half of why the drift was invisible. Both observations are in the report to PM; the GitHub search quota was exhausted at the time, so the mandatory dedup search for a new card could not be run and filing blind would have been worse than handing it over.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…14678)
`GLOBAL_ACTION_OBJECT_KEY` exists so the object-less action-registration key
is written once. #14422 converged the owner-key ladder and the ObjectQL
plugin's copy of it; three bare `'global'` spellings elsewhere in
`packages/runtime/src/action-execution.ts` were never in that card's path,
because the runtime fence it built was a re-export plus a delegating alias.
All three are equal in value to the constant today — which is the defect, not
a mitigation: it is what made them invisible to every test in the repo, and
what would have let them part from the constant in silence.
- `seedFlowActionParams`: the live comparison deciding whether an
object-derived `<object>Id` param key is seeded. Substitution only — the
predicate is unchanged, so `'*'` still falls through exactly as before.
- `enforceActionParams`: the warn-once dedup key, also interpolated into the
operator-facing `[action-params] <key>: …` line.
- `collectActionDeclarations`'s docblock, which also called the key "the
wildcard" against `action-governance.ts`'s "no wildcard semantics". It now
reads as the sibling docblock 48 lines below already did.
`action-owner-key-single-source.test.ts` gains a half C that reads
`action-execution.ts` and fails if any quote spelling is written out by hand
again. The forbidden spelling is derived from the constant, so the guard is
not itself another copy of the literal it forbids.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

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

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

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33742671888 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA(日志不可读,点进 job 看)

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 73ad0bbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14678-action-execution-global-literals branch September 3, 2026 10:46
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

3 participants

@os-trump@os-project-manager@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

refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts - #14861

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals
Sep 3, 2026
Merged

refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts#14861
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14678

File face: packages/runtime/src/action-execution.ts, and nothing else.packages/runtime/src/sandbox/** is untouched — PR #14826 is armed on that face with card #14760 queued behind it. packages/spec is read-only here: it owns one site in this class and that site is named below rather than edited.

Re-measured before editing, because the card said to

$ git grep -n "'global'" origin/main -- packages/runtime/src/action-execution.ts
origin/main:packages/runtime/src/action-execution.ts:641: if (objectName && objectName !== 'global') {
origin/main:packages/runtime/src/action-execution.ts:1016: const key = `${where.objectName ?? 'global'}/${where.actionName ?? action?.name ?? 'action'}`;
origin/main:packages/runtime/src/action-execution.ts:1490: * legacy `object` field, else the `'global'` wildcard).

Three, at the three line numbers the card's current table gives. Neither of the two corrections that moved this card applies any more: the withdrawn fourth site at :1539 is gone, and the numbers have not shifted since 75adf11.

Widened past the single-quote spelling, in case the count was an artefact of the grep rather than of the tree:

$ git grep -nE "(['\"`])global\1|\bglobal\b" origin/main -- packages/runtime/src/action-execution.ts

adds only :962pass \undefined` for a global parent object schema` — the English adjective, not the token. So three is the real population, not three-of-some-quote-style.

What changed

SiteKindCall
seedFlowActionParamslive comparisonconverged
enforceActionParamswarn-once log keyconverged, argued below
collectActionDeclarations docblockprose, plus a second defectconverged

seedFlowActionParams is the one that matters — a live comparison deciding whether an object-derived Id param key is seeded into an action's bag. Pure substitution: objectName !== GLOBAL_ACTION_OBJECT_KEY. The predicate is byte-for-byte the same test, so the legacy '*' key still falls through exactly as it did (see Residue).

enforceActionParams is the judgement the card asked to be made out loud. The case for leaving a literal is that a warn-once dedup key — which is also interpolated into the operator-facing [action-params] … line — must never fail to render. That argument does not survive contact with what the alternative actually is: GLOBAL_ACTION_OBJECT_KEY is a module-scope const string, already imported into this file, already interpolated by the sibling site. It cannot fail to render either. What a drift there really costs is an operator grepping logs by the key the engine now uses and silently missing these lines. So: converged. Note the deliberate asymmetry left in place one interpolation to the right — the ?? 'action' fallback in the same template stays a bare literal, because it is a display placeholder with no constant behind it and no engine concept underneath. Same token shape, different role; that is the whole distinction this card turns on.

The docblock carried a second defect independent of the literal: it called the key "the 'global' wildcard", contradicting action-governance.ts — "engine.executeAction is an exact-string Map lookup with no wildcard semantics". Both defects close together, and the replacement is not invented: it is the phrasing the sibling docblock 48 lines below already used, so the file now says the same thing in both places.

grep -c for all three quote spellings of the token in this file is now 0, 0, 0.

The sweep, and how true instances were told from same-token noise

The token is everywhere — 626 occurrences across 160 source files. Nearly all of them are a different vocabulary that happens to share a word, and the difference is invisible from the token:

$ git grep -c "'global'" origin/main -- 'packages/**/*.ts' 'packages/**/*.tsx' \
'apps/**/*.ts' 'apps/**/*.tsx' 'examples/**/*.ts' 'scripts/**' | awk -F: '{s+=$2} END {print s, NR}'
626 160

The discriminator is not the token, it is the role: does this value become, or get compared against, the object half of the engine's objectName:actionName registration key that executeAction looks up? Applying it splits the population into one true class and five unrelated vocabularies:

  • ADR-0120 uniqueness scope (unique: 'global' | 'organization') — by far the largest bucket, and the one that would have swallowed a token-level sweep whole.
  • Settings / storage / permission scope (scope: 'global' | 'tenant' | 'user'), lifecycle and tenancy scope, automation lockScope, and app payload fields such as the showcase action's own scope: 'global' return value.

None of those five ever reaches an action registration key. That leaves the true class — every site where the value really is the object-less action key:

SiteVerdict
objectql/src/action-governance.ts:61the declaration itself — the single source, not a defect
objectql/src/action-governance.ts:87prose quoting the anti-pattern it replaced; correct as written
runtime/src/action-execution.ts ×3converged here
cli/src/commands/lint.ts:321true instance — left, see below
client/src/index.ts:4269true instance — left, see below
spec/src/stack.zod.ts:1537deliberate second spelling — left, see below
runtime/src/domains/actions.ts ×4prose only; live code already converged — left
cli/src/lint/hook-body-lowering.ts:279, cli/src/utils/lower-callables.ts:198false positives by role

What was deliberately left, and why

packages/cli/src/commands/lint.ts:321 is a genuine instance — it builds the engine registration key with the same objectName-else-literal ladder, and its own comment says so ("it is the literal the engine really registers under"). It is not converged here for a reason that is not taste: that file is claimed.#14669 is open and pm:dispatched on packages/cli/src/commands/lint.ts:298 — twenty-three lines above, inside the same docblock. Editing around another seat's live claim buys a conflict and nothing else. #14669's own body also states the precedent for the cross-package half: pulling @objectstack/cli in "would pull both into turbo's affected set and add a verification surface to a p3 convergence card".

packages/client/src/index.ts:4269 (invokeGlobal dispatching to invoke('global', …)) is a true instance that cannot be converged as things stand. @objectstack/client depends on @objectstack/core and @objectstack/spec and nothing else — it has no path to GLOBAL_ACTION_OBJECT_KEY without giving the browser SDK an engine dependency. That is an architecture decision, not a p3 cleanup.

packages/spec/src/stack.zod.ts:1537 re-declares the value as GLOBAL_ACTION_SCOPE with a docblock stating exactly why — "spelled here rather than imported because the spec sits below every runtime package". Off-limits to this seat by fence, and it is a documented second spelling rather than an accidental one. Naming it, not touching it.

packages/runtime/src/domains/actions.ts carries four quoted spellings, all in comments; its live code already resolves through actionExec.GLOBAL_ACTION_OBJECT_KEY at :431. Two of the four describe the literal URL path segment/actions/global/:action, which is a different thing that happens to share the word; the other two narrate the historical #3913 defect and are statements about a measured past. None carries the wildcard contradiction. Left, and the reason is on the record here rather than in silence.

The two packages/cli lowering sites are false positives by role, which is worth showing rather than asserting. 'global' there is an ownerLabel that becomes a generated handler name prefix (global_myAction) for the callable registry, and a diagnostic string. It never becomes an objectName. If the constant moved, those labels would produce a differently-named handler ref — consistently, within the same lowering pass. No coupling, so no defect.

Can the next one be made loud?

Yes, and cheaply enough that declining would have been the harder argument. action-owner-key-single-source.test.ts already exists in this package for exactly this weld — half A pins the behaviour, half B pins the structure. It gains a half C that reads action-execution.ts and fails if any quote spelling of the key is written by hand again.

The forbidden spelling is derived from the constant, not hard-coded:

constBARE_LITERALS: readonlystring[]=[`'${GLOBAL_ACTION_OBJECT_KEY}'`,`"${GLOBAL_ACTION_OBJECT_KEY}"`,`\`${GLOBAL_ACTION_OBJECT_KEY}\``,];

A hard-coded 'global' there would have been a fourth copy of the very literal the file exists to forbid, going stale in the same silence. Derived, the guard follows the constant wherever it goes.

This is proportionate for a p3 because it costs no new verification surface: an existing test file, in the affected package, reading a file it already reads, under gate families this card already owes.

It was verified to fail, twice, for two different reasons — a guard nobody has watched go red is a guard nobody has tested:

MutationExpectedObserved
re-inline the literal at the live comparisonredred — tripped the anti-vacuity control first
re-inline at the log key, leaving the control text intactred on the negative armred, with the intended message

Both legs proved the mutation reached disk before reading any result (injected-text and removed-text counts, plus a git hash-object comparison against the HEAD blob), restored with git checkout HEAD --, and proved the restore byte-identical (git diff HEAD empty, hash equal to the HEAD blob) before re-running green. No rebuild was needed for either leg and none is claimed: half C reads source text at runtime, and the constant it derives from comes from @objectstack/objectql's already-built dist, which no mutation here touches.

Changeset fork

patch on @objectstack/runtime, not skip-changeset.packages/runtime's files whitelist is ["dist", "README.md", "CHANGELOG.md"]src is not published raw, but dist is built from it, so the emitted output moves even though the behaviour does not. That is outside the skip-changeset criterion, which needs a diff that publishes nothing from any package. Nothing reaches the published entry either — no export, signature or type changed, and action-execution.ts is not re-exported from packages/runtime/src/index.ts — which is what keeps it below minor. This is the same fork and the same measurement PR #14667 took for this package on this file.

Readings — all at d1ec90e7, the head of this branch

Dependency closure built first (pnpm --filter '@objectstack/runtime^...' build), so nothing below is a stale-dist reading.

  • pnpm --filter @objectstack/runtime typecheck — exit 0. Run by name, so it chained the second link: 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 (shrink-only and identity-pinned). Nothing was added to that ledger. The edited test file is not in it, and tsc -p tsconfig.test.json --listFiles reaches it — so this is a measurement of the new code, not a green that skipped it.
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 action23 files, 366 tests, all passing. Half C is in the verbose transcript, so it ran rather than being collected and skipped.
  • Fourteen no-build gate families re-derived from the real diff, all green, each quoted by its own verdict line rather than by an exit code: nul-bytes (OK … no raw ASCII control bytes), swallow-census-controls, dispatcher-error-vocabulary, engine-double-contract (OK — 759 pinned, 133 in the DEBT ledger, 3 exempt), where-matcher (332 matcher(s) discovered, 332 answer … correctly), cross-package-test-inputs (OK: 25 package(s) read outside themselves, all declared), test-source-alias, published-files, doc-authoring, objectql-double-limit, query-options-erasure, changeset-gate-self-tests, objectui-changeset, pm-half-states.

The family list was re-derived from the actual change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack rather than from the dispatch note: 36 families, 31 by path and 7 by change kind. The heavy remainder — the full lint farm, the workspace typecheck, and check:type-check-debt --re-measure, which needs the whole workspace closure built — is left to CI, which runs it once regardless.

Residue, recorded rather than fixed

seedFlowActionParams guards with objectName !== GLOBAL_ACTION_OBJECT_KEY, but isObjectLessActionKey — exported from the same module and already imported into this file — also treats the legacy '*' key and the empty string as object-less. A route resolved at '*' therefore reaches the branch and seeds a *Id param key. Inert in practice, since #3913 left nothing registering under '*', and correcting it would be a behaviour change rather than a spelling one, so it is deliberately not in this PR. Neither of the two functions edited here is named by any test in the repo, which is the other half of why the drift was invisible. Both observations are in the report to PM; the GitHub search quota was exhausted at the time, so the mandatory dedup search for a new card could not be run and filing blind would have been worse than handing it over.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…14678)
`GLOBAL_ACTION_OBJECT_KEY` exists so the object-less action-registration key
is written once. #14422 converged the owner-key ladder and the ObjectQL
plugin's copy of it; three bare `'global'` spellings elsewhere in
`packages/runtime/src/action-execution.ts` were never in that card's path,
because the runtime fence it built was a re-export plus a delegating alias.
All three are equal in value to the constant today — which is the defect, not
a mitigation: it is what made them invisible to every test in the repo, and
what would have let them part from the constant in silence.
- `seedFlowActionParams`: the live comparison deciding whether an
object-derived `<object>Id` param key is seeded. Substitution only — the
predicate is unchanged, so `'*'` still falls through exactly as before.
- `enforceActionParams`: the warn-once dedup key, also interpolated into the
operator-facing `[action-params] <key>: …` line.
- `collectActionDeclarations`'s docblock, which also called the key "the
wildcard" against `action-governance.ts`'s "no wildcard semantics". It now
reads as the sibling docblock 48 lines below already did.
`action-owner-key-single-source.test.ts` gains a half C that reads
`action-execution.ts` and fails if any quote spelling is written out by hand
again. The forbidden spelling is derived from the constant, so the guard is
not itself another copy of the literal it forbids.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

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

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

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33742671888 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA(日志不可读,点进 job 看)

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 73ad0bbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14678-action-execution-global-literals branch September 3, 2026 10:46
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

3 participants

@os-trump@os-project-manager@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

refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts - #14861

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals
Sep 3, 2026
Merged

refactor(runtime): spell the object-less action key as GLOBAL_ACTION_OBJECT_KEY in action-execution.ts#14861
os-project-manager merged 2 commits into
mainfrom
claude/issue-14678-action-execution-global-literals

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14678

File face: packages/runtime/src/action-execution.ts, and nothing else.packages/runtime/src/sandbox/** is untouched — PR #14826 is armed on that face with card #14760 queued behind it. packages/spec is read-only here: it owns one site in this class and that site is named below rather than edited.

Re-measured before editing, because the card said to

$ git grep -n "'global'" origin/main -- packages/runtime/src/action-execution.ts
origin/main:packages/runtime/src/action-execution.ts:641: if (objectName && objectName !== 'global') {
origin/main:packages/runtime/src/action-execution.ts:1016: const key = `${where.objectName ?? 'global'}/${where.actionName ?? action?.name ?? 'action'}`;
origin/main:packages/runtime/src/action-execution.ts:1490: * legacy `object` field, else the `'global'` wildcard).

Three, at the three line numbers the card's current table gives. Neither of the two corrections that moved this card applies any more: the withdrawn fourth site at :1539 is gone, and the numbers have not shifted since 75adf11.

Widened past the single-quote spelling, in case the count was an artefact of the grep rather than of the tree:

$ git grep -nE "(['\"`])global\1|\bglobal\b" origin/main -- packages/runtime/src/action-execution.ts

adds only :962pass \undefined` for a global parent object schema` — the English adjective, not the token. So three is the real population, not three-of-some-quote-style.

What changed

SiteKindCall
seedFlowActionParamslive comparisonconverged
enforceActionParamswarn-once log keyconverged, argued below
collectActionDeclarations docblockprose, plus a second defectconverged

seedFlowActionParams is the one that matters — a live comparison deciding whether an object-derived Id param key is seeded into an action's bag. Pure substitution: objectName !== GLOBAL_ACTION_OBJECT_KEY. The predicate is byte-for-byte the same test, so the legacy '*' key still falls through exactly as it did (see Residue).

enforceActionParams is the judgement the card asked to be made out loud. The case for leaving a literal is that a warn-once dedup key — which is also interpolated into the operator-facing [action-params] … line — must never fail to render. That argument does not survive contact with what the alternative actually is: GLOBAL_ACTION_OBJECT_KEY is a module-scope const string, already imported into this file, already interpolated by the sibling site. It cannot fail to render either. What a drift there really costs is an operator grepping logs by the key the engine now uses and silently missing these lines. So: converged. Note the deliberate asymmetry left in place one interpolation to the right — the ?? 'action' fallback in the same template stays a bare literal, because it is a display placeholder with no constant behind it and no engine concept underneath. Same token shape, different role; that is the whole distinction this card turns on.

The docblock carried a second defect independent of the literal: it called the key "the 'global' wildcard", contradicting action-governance.ts — "engine.executeAction is an exact-string Map lookup with no wildcard semantics". Both defects close together, and the replacement is not invented: it is the phrasing the sibling docblock 48 lines below already used, so the file now says the same thing in both places.

grep -c for all three quote spellings of the token in this file is now 0, 0, 0.

The sweep, and how true instances were told from same-token noise

The token is everywhere — 626 occurrences across 160 source files. Nearly all of them are a different vocabulary that happens to share a word, and the difference is invisible from the token:

$ git grep -c "'global'" origin/main -- 'packages/**/*.ts' 'packages/**/*.tsx' \
'apps/**/*.ts' 'apps/**/*.tsx' 'examples/**/*.ts' 'scripts/**' | awk -F: '{s+=$2} END {print s, NR}'
626 160

The discriminator is not the token, it is the role: does this value become, or get compared against, the object half of the engine's objectName:actionName registration key that executeAction looks up? Applying it splits the population into one true class and five unrelated vocabularies:

  • ADR-0120 uniqueness scope (unique: 'global' | 'organization') — by far the largest bucket, and the one that would have swallowed a token-level sweep whole.
  • Settings / storage / permission scope (scope: 'global' | 'tenant' | 'user'), lifecycle and tenancy scope, automation lockScope, and app payload fields such as the showcase action's own scope: 'global' return value.

None of those five ever reaches an action registration key. That leaves the true class — every site where the value really is the object-less action key:

SiteVerdict
objectql/src/action-governance.ts:61the declaration itself — the single source, not a defect
objectql/src/action-governance.ts:87prose quoting the anti-pattern it replaced; correct as written
runtime/src/action-execution.ts ×3converged here
cli/src/commands/lint.ts:321true instance — left, see below
client/src/index.ts:4269true instance — left, see below
spec/src/stack.zod.ts:1537deliberate second spelling — left, see below
runtime/src/domains/actions.ts ×4prose only; live code already converged — left
cli/src/lint/hook-body-lowering.ts:279, cli/src/utils/lower-callables.ts:198false positives by role

What was deliberately left, and why

packages/cli/src/commands/lint.ts:321 is a genuine instance — it builds the engine registration key with the same objectName-else-literal ladder, and its own comment says so ("it is the literal the engine really registers under"). It is not converged here for a reason that is not taste: that file is claimed.#14669 is open and pm:dispatched on packages/cli/src/commands/lint.ts:298 — twenty-three lines above, inside the same docblock. Editing around another seat's live claim buys a conflict and nothing else. #14669's own body also states the precedent for the cross-package half: pulling @objectstack/cli in "would pull both into turbo's affected set and add a verification surface to a p3 convergence card".

packages/client/src/index.ts:4269 (invokeGlobal dispatching to invoke('global', …)) is a true instance that cannot be converged as things stand. @objectstack/client depends on @objectstack/core and @objectstack/spec and nothing else — it has no path to GLOBAL_ACTION_OBJECT_KEY without giving the browser SDK an engine dependency. That is an architecture decision, not a p3 cleanup.

packages/spec/src/stack.zod.ts:1537 re-declares the value as GLOBAL_ACTION_SCOPE with a docblock stating exactly why — "spelled here rather than imported because the spec sits below every runtime package". Off-limits to this seat by fence, and it is a documented second spelling rather than an accidental one. Naming it, not touching it.

packages/runtime/src/domains/actions.ts carries four quoted spellings, all in comments; its live code already resolves through actionExec.GLOBAL_ACTION_OBJECT_KEY at :431. Two of the four describe the literal URL path segment/actions/global/:action, which is a different thing that happens to share the word; the other two narrate the historical #3913 defect and are statements about a measured past. None carries the wildcard contradiction. Left, and the reason is on the record here rather than in silence.

The two packages/cli lowering sites are false positives by role, which is worth showing rather than asserting. 'global' there is an ownerLabel that becomes a generated handler name prefix (global_myAction) for the callable registry, and a diagnostic string. It never becomes an objectName. If the constant moved, those labels would produce a differently-named handler ref — consistently, within the same lowering pass. No coupling, so no defect.

Can the next one be made loud?

Yes, and cheaply enough that declining would have been the harder argument. action-owner-key-single-source.test.ts already exists in this package for exactly this weld — half A pins the behaviour, half B pins the structure. It gains a half C that reads action-execution.ts and fails if any quote spelling of the key is written by hand again.

The forbidden spelling is derived from the constant, not hard-coded:

constBARE_LITERALS: readonlystring[]=[`'${GLOBAL_ACTION_OBJECT_KEY}'`,`"${GLOBAL_ACTION_OBJECT_KEY}"`,`\`${GLOBAL_ACTION_OBJECT_KEY}\``,];

A hard-coded 'global' there would have been a fourth copy of the very literal the file exists to forbid, going stale in the same silence. Derived, the guard follows the constant wherever it goes.

This is proportionate for a p3 because it costs no new verification surface: an existing test file, in the affected package, reading a file it already reads, under gate families this card already owes.

It was verified to fail, twice, for two different reasons — a guard nobody has watched go red is a guard nobody has tested:

MutationExpectedObserved
re-inline the literal at the live comparisonredred — tripped the anti-vacuity control first
re-inline at the log key, leaving the control text intactred on the negative armred, with the intended message

Both legs proved the mutation reached disk before reading any result (injected-text and removed-text counts, plus a git hash-object comparison against the HEAD blob), restored with git checkout HEAD --, and proved the restore byte-identical (git diff HEAD empty, hash equal to the HEAD blob) before re-running green. No rebuild was needed for either leg and none is claimed: half C reads source text at runtime, and the constant it derives from comes from @objectstack/objectql's already-built dist, which no mutation here touches.

Changeset fork

patch on @objectstack/runtime, not skip-changeset.packages/runtime's files whitelist is ["dist", "README.md", "CHANGELOG.md"]src is not published raw, but dist is built from it, so the emitted output moves even though the behaviour does not. That is outside the skip-changeset criterion, which needs a diff that publishes nothing from any package. Nothing reaches the published entry either — no export, signature or type changed, and action-execution.ts is not re-exported from packages/runtime/src/index.ts — which is what keeps it below minor. This is the same fork and the same measurement PR #14667 took for this package on this file.

Readings — all at d1ec90e7, the head of this branch

Dependency closure built first (pnpm --filter '@objectstack/runtime^...' build), so nothing below is a stale-dist reading.

  • pnpm --filter @objectstack/runtime typecheck — exit 0. Run by name, so it chained the second link: 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 (shrink-only and identity-pinned). Nothing was added to that ledger. The edited test file is not in it, and tsc -p tsconfig.test.json --listFiles reaches it — so this is a measurement of the new code, not a green that skipped it.
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 action23 files, 366 tests, all passing. Half C is in the verbose transcript, so it ran rather than being collected and skipped.
  • Fourteen no-build gate families re-derived from the real diff, all green, each quoted by its own verdict line rather than by an exit code: nul-bytes (OK … no raw ASCII control bytes), swallow-census-controls, dispatcher-error-vocabulary, engine-double-contract (OK — 759 pinned, 133 in the DEBT ledger, 3 exempt), where-matcher (332 matcher(s) discovered, 332 answer … correctly), cross-package-test-inputs (OK: 25 package(s) read outside themselves, all declared), test-source-alias, published-files, doc-authoring, objectql-double-limit, query-options-erasure, changeset-gate-self-tests, objectui-changeset, pm-half-states.

The family list was re-derived from the actual change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack rather than from the dispatch note: 36 families, 31 by path and 7 by change kind. The heavy remainder — the full lint farm, the workspace typecheck, and check:type-check-debt --re-measure, which needs the whole workspace closure built — is left to CI, which runs it once regardless.

Residue, recorded rather than fixed

seedFlowActionParams guards with objectName !== GLOBAL_ACTION_OBJECT_KEY, but isObjectLessActionKey — exported from the same module and already imported into this file — also treats the legacy '*' key and the empty string as object-less. A route resolved at '*' therefore reaches the branch and seeds a *Id param key. Inert in practice, since #3913 left nothing registering under '*', and correcting it would be a behaviour change rather than a spelling one, so it is deliberately not in this PR. Neither of the two functions edited here is named by any test in the repo, which is the other half of why the drift was invisible. Both observations are in the report to PM; the GitHub search quota was exhausted at the time, so the mandatory dedup search for a new card could not be run and filing blind would have been worse than handing it over.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…14678)
`GLOBAL_ACTION_OBJECT_KEY` exists so the object-less action-registration key
is written once. #14422 converged the owner-key ladder and the ObjectQL
plugin's copy of it; three bare `'global'` spellings elsewhere in
`packages/runtime/src/action-execution.ts` were never in that card's path,
because the runtime fence it built was a re-export plus a delegating alias.
All three are equal in value to the constant today — which is the defect, not
a mitigation: it is what made them invisible to every test in the repo, and
what would have let them part from the constant in silence.
- `seedFlowActionParams`: the live comparison deciding whether an
object-derived `<object>Id` param key is seeded. Substitution only — the
predicate is unchanged, so `'*'` still falls through exactly as before.
- `enforceActionParams`: the warn-once dedup key, also interpolated into the
operator-facing `[action-params] <key>: …` line.
- `collectActionDeclarations`'s docblock, which also called the key "the
wildcard" against `action-governance.ts`'s "no wildcard semantics". It now
reads as the sibling docblock 48 lines below already did.
`action-owner-key-single-source.test.ts` gains a half C that reads
`action-execution.ts` and fails if any quote spelling is written out by hand
again. The forbidden spelling is derived from the constant, so the guard is
not itself another copy of the literal it forbids.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

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

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

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33742671888 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA(日志不可读,点进 job 看)

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 73ad0bbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14678-action-execution-global-literals branch September 3, 2026 10:46
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

3 participants

@os-trump@os-project-manager@claude