Skip to content

fix(spec/automation)!: refuse a $ name at every remaining flow binding — loop / map iterator and index, screen idVariable and field name, declared variables, assignment targets - #22746

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22572-dollar-binding-keys
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22572-dollar-binding-keys

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22572
Clause-②: no (narrowing)

What this does

This is the close-out of the family "the $ names are the flow engine's at every binding door", after #22477 (the read side) and #22502 (outputVariable, errorVariable). Every remaining place a flow binds a variable by name now refuses a name whose first non-blank character is $. It uses the one rule PR #22569 added, flowBoundVariableNameSchema in packages/spec/src/automation/flow-bound-variable-name.ts. The rule stays package-internal, so there is no new export (check:api-surface is green).

binding position contract how the published JSON Schema states it
loop iteratorVariable / indexVariable LoopConfigSchema pattern (default item and minLength: 1 kept)
map iteratorVariable / indexVariable MapConfigSchema pattern (default item kept)
object-form screen idVariable ScreenConfigSchema pattern
screen field name (an in-place addition, see below) ScreenFieldConfigSchema pattern
declared variable name FlowVariableSchema pattern
assignment target, a key of the assignments map AssignmentConfigSchema (key schema) propertyNames.pattern
assignment target, a top-level key of a bare config AssignmentConfigSchema (superRefine) not stated: one new declared site in dropped-refinements.baseline.json
assignment target, a legacy [{ variable, value }] item the FlowSchema arm not stated (the contract refuses the array form whole)

One sentence at every door. The refusal is the rule's own message. It says the $ names are reserved for the engine, names the key, and gives the remedy: the same name without the $, read as {{ name }}.

  • loop, map and screen keys. These are refused through flowNodeConfigRefusals, so FlowSchema.parse, registerFlow, objectstack validate and the run itself (parseNodeConfig) all refuse them at nodes.N.config.KEY.
  • FlowVariableSchema.name. FlowSchema refuses it directly, at variables.N.name.
  • assignment targets. No executor contract parses an assignment config, because getBuiltinNodeConfigContracts() has no assignment entry. So FlowSchema.parse never applied AssignmentConfigSchema. A new arm in the FlowSchema superRefine walks collectFlowGraphs, region bodies included. It judges flowAssignmentTargets(config), which covers the three shapes the executor binds (service-automation builtin/logic-nodes.ts) and only those.

The first non-blank character, at every binding key. The screen and script executors trim a name before they bind it (cfg.idVariable.trim(), cfg.outputVariable?.trim()). So idVariable: ' $id' passed a first-character rule. The rule's regex is now ^\s*(?:[^$\s][\s\S]*)?$ (with \$error| for errorVariable). JavaScript's \s is exactly the set String.prototype.trim removes, so the rule refuses a name exactly when its trimmed form starts with $. This applies to outputVariable and errorVariable too. #22502's changeset is still unreleased on the same 18.0.0-next line.

Additions beyond the card's list (bounded in-place fixes; all four conditions hold)

The fixes below meet all four conditions: the same defect class, a mechanical fix (compose the one rule), the claim's own file with no other claim on it, and the same gate family.

  1. ScreenFieldConfigSchema.name. Its own describe says "the flow variable the value binds to". The resume that submits a flat screen writes each field's value under its name. A $ name there makes the screen impossible to submit (measured below).
  2. The leading blank, as above. Measured: under the first-character rule, idVariable: ' $id' registered. The paused screen then named $id, and its resume answered INVALID_SIGNAL.

The claim's file surface needs two more entries: ScreenFieldConfigSchema (in builtin-node-config.zod.ts, already listed) and packages/spec/dropped-refinements.baseline.json. That file is a forced debt of the bare-shape key rule: build-schemas.ts refuses the build until the site is declared, and its header total moves from 704 to 705. The four regenerated reference pages are within "as check:generated decides".

The PM's mechanism assumptions, measured at origin/main 0f77ff5202

  1. The sites. Every one accepted a $ name, measured with each contract's safeParse and FlowSchema.safeParse against the built dist:

    position at 0f77ff5202 input result
    LoopConfigSchema.iteratorVariable control-flow.zod.ts:220, z.string().min(1).default('item') '$row' accepted
    LoopConfigSchema.indexVariable :222 '$i' accepted
    MapConfigSchema.iteratorVariable builtin-node-config.zod.ts:1059, z.string().default('item') (where [v18] retire the {var} template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939 S1 left the block) '$row' accepted
    MapConfigSchema.indexVariable :1061 '$i' accepted
    ScreenConfigSchema.idVariable :862 '$id' accepted
    ScreenFieldConfigSchema.name :700 (not on the card) '$f' accepted
    AssignmentConfigSchema, the assignments map key :1163, z.record(z.string().min(1), ...) '$y' accepted
    AssignmentConfigSchema, a bare top-level key .catchall() sees values, never keys '$y' accepted
    FlowVariableSchema.name flow.zod.ts:218, z.string() '$x' accepted
    FlowSchema with variables: [{ name: '$x' }] and assignment { assignments: { $y: 1 } } accepted
  2. Defaults. Both iteratorVariable defaults stay item (pinned). The published JSON carries pattern beside default: "item". check:authorable-surface is green, and authorable-defaults/automation.json is unchanged.

  3. The engine's own names. No shipped flow or fixture binds a $ name on these positions (item 4). The run-time effect was measured with AutomationEngine at 0f77ff5202, through a scratch test that is not committed:

    • loop with iteratorVariable: '$record' and indexVariable: '$runId': after the loop, a screen titled record is {{ $record }}, runId is {{ $runId }} rendered record is B, runId is 1.
    • map with iteratorVariable: '$record': record is B.
    • assignment with { assignments: { $record: 'clobbered' } }: record is clobbered.
    • a declared $record with defaultValue: 'mine': the engine's own seeding overwrote it, and the title showed the trigger record.
    • object-form screen with idVariable: '$id': the run paused, and the resume the console sends ({ $id: id }, FlowRunner.tsx onObjectFormSaved at the pinned objectui 20c6d351ad) answered INVALID_SIGNAL.
    • flat screen field name: '$x': the same INVALID_SIGNAL.
    • After this change, registerFlow refuses all six flows: a ZodError from canonicalizeStoredFlow at the binding path.
  4. The reach: zero newly refused bindings.

    • The real parse. All 35 flows the examples ship (app-crm 1, app-todo 4, app-showcase 30) were run through FlowSchema.safeParse. All parse OK, and the before and after listings are byte-identical.
    • An AST scan. The scanner reads TypeScript object-literal properties, plus a text arm for JSON and YAML. On a control fixture it found 11 of 11 planted positives. It covered examples (234 files), packages/platform-objects (167 files; it ships no flow), packages/qa/dogfood (280), the rest of packages, skills, apps and scripts, and hotcrm at 1d7148bf2d (570 files; a read-only clone, deleted afterwards). It found no $-led binding on any position. Its only hits were errorVariable: '$error', which stays legal, and filter operator keys.
    • The pinned objectui 20c6d351ad. None. The control (errorVariable: '$error') hit.
  5. The enumeration pin. The pin finds binding keys by name. It walks the JSON Schema of every Zod export of automation/index.ts (72 schemas) for any property spelled *Variable, at any depth. Each one must publish the rule's pattern, be in the rule's vocabulary, and have a row in the pin's site table. A floor keeps the walk from passing over nothing.

    • The honest limit. A binding position not spelled *Variable cannot be found this way: a field's name, a variable's name, an assignments key. Those three are hand-listed. They are the rule's exported vocabulary FLOW_BINDING_KEYS, which the pin holds equal to the site table.

ADR-0087 disposition

Tests (at af752544fb)

  • New pins, in packages/spec/src/automation/flow-bound-variable-name.test.ts. The file went from 26 to 115 tests. The pin table has 18 binding sites, and each site:

    • is refused by its contract, with the remedy;
    • is refused by FlowSchema at exactly its path, and nowhere else;
    • refuses the engine's names $record, $runId, $loopItems and $;
    • refuses a leading blank (' $x', a tab, a newline);
    • accepts the controls x, a$b and ' x'.

    The file also pins:

    • the defaults;
    • defineStack refusing a declared $total with { code: 'STACK_SCHEMA_INVALID', status: 422 } at flows.0.variables.0.name;
    • the remedy reading clean ({{ row.name }} in a loop body);
    • flowAssignmentTargets over the three shapes, and an assignment inside a region body;
    • the vocabulary pin, the discovery pin and the published-pattern pin;
    • the D3 entry and its rationale fragment.
  • @objectstack/spec, vitest run --project local --maxWorkers=2: 642 files, 19286 passed, 1 todo. pnpm --filter @objectstack/spec typecheck exits 0, with check:test-typecheck: OK.

  • @objectstack/service-automation, the whole suite against the rebuilt spec: 185 files, 2368 passed. Its typecheck exits 0.

  • @objectstack/lint, the whole suite: 135 files, 6321 passed. Its typecheck exits 0.

  • The CLI guidance test, vitest run --project integration test/migrate-meta-engine-guidance.test.ts, after the CLI closure build (turbo run build --filter=@objectstack/cli^...): 3 passed. It holds the new entry's printed guidance.

  • pnpm --filter @objectstack/spec check:generated: all 15 generated artifacts are up to date, after a rebuild at the final source.

Ablation

Each ablation ran against the committed fix, with the mutation and restore going through scripts/ablation-replace.mjs in wrap mode. The spec tests import src, so no dist leg applies.

  1. The rule dropped from MapConfigSchema.indexVariable. The anchor went from 1 to 0, and the blob from cbe18e1969b2 to ca9af2dd1eb3.
    • Result: 4 failed, 95 passed. Red: the map indexVariable contract, flow-door and engine-name pins, and the discovery pin with MapConfigSchema.indexVariable publishes no pattern.
    • Restore: the blob equals HEAD (cbe18e1969b2), and git diff HEAD is empty.
  2. The leading-blank half dropped ([^$\s] back to [^$]). The blob went from b7564636d07e to ae5b2c3c1277.
    • Result: 18 failed, 97 passed: all 16 leading-blank rows, plus both published-pattern pins.
    • Restore: the blob equals HEAD (b7564636d07e), and git diff HEAD is empty.

Gates (at af752544fb)

  • The derived gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths, merge base f59a73c39) derived 114 commands. 112 were run, all exit 0. check:skill-examples first exited 3 (PREREQUISITE NOT MET: client-react was not built). After building client and client-react, its re-run read 262 prose examples type-check.
  • The reconciliation. --ran reads 114 derived, 112 run, 0 NOT-MEASURED, 2 UNRUN.
  • NOT MEASURED: check:dual-build-cjs-loads. Reason: it needs a whole-workspace build, which the dispatch rules out.
  • NOT MEASURED: check:type-check-debt. Reason: its script is --re-measure, which the dispatch rules out.
  • NOT MEASURED: the workspace typecheck lane. Reason: it needs a whole-workspace build. The type surface is byte-unchanged (check:api-surface is green), because a regex check changes no TypeScript type.
  • The artifact-roster block. It is run separately. Its three PR-context guards are wired to this PR after it opened, and their readings are in the report on spec(automation): the $ namespace at every binding door: loop and map iteratorVariable / indexVariable, a screen's idVariable, a declared flow variable's name and an assignment target still bind a $ name a text slot refuses to read #22572.

Acceptance notes

  • The two strays triage named stay noted: the service-automation executor configSchema descriptors, and the engine.ts buildSubflowResumeSignal comment. This PR touches no service-automation file.
  • A body-less legacy loop. Its values are not judged at the flow door. This is the contract map's parsedWhen, unchanged since build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982. Its iteratorVariable is never read; the executor sets $loopItems and $loopIndex itself.
  • map.input and subflow.input keys name the callee's variables, so they are not bindings in this flow. A callee can no longer declare a $ variable, so a $ key there binds nothing.
  • api/automation-api.zod.ts ScreenSpec.idVariable is a response shape the engine produces, not an authoring door. It carries no rule.
  • content/docs/automation/flows.mdx (hand-written) still names only outputVariable beside errorVariable in its paragraph on where the $ names belong. The paragraph is still true, and it is outside the claim's surface. It is not widened here.

Generated by Claude Code

A loop or map iteratorVariable / indexVariable, an object-form screen's
idVariable, a screen field's name, a declared flow variable's name and an
assignment node's targets now refuse a name that starts with `$`, by the
one rule (`flowBoundVariableNameSchema`) outputVariable and errorVariable
already compose. ADR-0087 D3 entry `flow-binding-name-dollar-refused`.

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <noreply@anthropic.com>
…ed refinement

The `$` rule on a bare legacy `assignment` config's top-level keys is a
superRefine (a catchall sees values, never keys), so the published JSON
Schema cannot state it; the ledger names the new site.

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <noreply@anthropic.com>
The `screen` and `script` executors trim a binding name before they bind
it, so `idVariable: ' $id'` passed a first-character rule and its screen
then named `$id`, refused on resume. The rule now refuses a name whose
first non-blank character is `$`, at every binding key.

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: af752544fb6bd4af362812b9c92e5ef20f9c93c5
Local-runs: none

Reviewed read-only on the net diff of PR #22746 against its merge base with origin/main (f59a73c395: 13 files, +771 / −69), card #22572 (body, triage 6092537456, the serial notes, claim 6102582908, the os-dev-report, ACCEPT 6103439893), PR #22569 (#22502, the rule's origin and the D3 precedent flow-binding-variable-dollar-name-refused), and the head's check-runs read at 2026-10-10T23:59Z. Executors and spec modules cited below are read at the merge base.

① Derived judgments

The accept-set narrowings, each judged against the executor that binds the name:

  1. LoopConfigSchema.iteratorVariable / indexVariable (control-flow.zod.ts) — right. flowBoundVariableNameSchema(key) composed; .min(1).default('item') kept on the iterator, .optional() on the index; the published JSON Schema carries pattern beside default: "item". loop-node.ts:127-128 binds both verbatim with variables.set, so a $record binding there overwrote the engine's record for the rest of the run (the dev's measured silent wrong run) — refusing it is the contract text's own answer.
  2. MapConfigSchema.iteratorVariable / indexVariable (builtin-node-config.zod.ts) — right. Same composition, .default('item') kept; map-node.ts:172-173 binds verbatim.
  3. ScreenConfigSchema.idVariable — right. screen-nodes.ts:233 binds cfg.idVariable.trim(), and applyResumeSignal (engine.ts:7786-7807) refuses any $-led key with INVALID_SIGNAL, so a $ id variable paused a screen nobody could submit.
  4. FlowVariableSchema.name (flow.zod.ts) — right. The engine seeds declared variables by name (engine.ts:12777-12782) and then its own $ names over them, so a declared $record lost its defaultValue — measured. FlowSchema refuses at variables.N.name; defineStack refuses with STACK_SCHEMA_INVALID / 422 at flows.0.variables.0.name (pinned).
  5. The assignments map key — right. The key schema of z.record(...) is the rule, published as propertyNames.pattern (pinned by the published-pattern test); the invalid_key issue lifts the rule's own sentence so the author reads the remedy. refuseRecordProtoKey returns the same schema type, so the record shape, and therefore propertyNames, survives.
  6. The bare legacy config's top-level keys — right. A .catchall() sees values only, so the new AssignmentConfigSchema.superRefine judges them through flowAssignmentTargets, skipping the assignments.* paths the key schema already owns; this is the one refinement the published JSON cannot state, declared as site out on automation/AssignmentConfig in dropped-refinements.baseline.json (704 → 705), which build-schemas.ts demands before it builds.
  7. The FlowSchema assignment arm — right, and it is the only door. getBuiltinNodeConfigContracts() deliberately carries no assignment entry ("three read-compatible shapes, no single contract", flow-node-config-refusals.ts), so flowNodeConfigRefusals never reached these targets. The arm walks collectFlowGraphs (region bodies included, pinned at nodes.1.config.body.nodes.0.config.assignments.$total) and judges flowAssignmentTargets, whose three shapes are exactly logic-nodes.ts:177-194: an array item's first non-null variable / name / key when a non-empty string; else the map's own keys; else every top-level key. No duplicate issue arises because no contract is applied by the flow door. The legacy array form is judged here only, since the contract refuses the array whole with the write-the-map prescription — consistent.
  8. ScreenFieldConfigSchema.name (beyond the card's list) — right, same class, in scope. screen-nodes.ts:287-316 forwards f.name verbatim (only an empty name is dropped), the resume writes the submitted value under it, and the same INVALID_SIGNAL refusal makes a $ field unsubmittable. The schema is consumed only by ScreenConfigSchema.fields (builtin-node-config.zod.ts:853), so no non-binding consumer is narrowed. Measured by the dev at the base. The four conditions of the in-place exemption hold on the diff (same defect class, compose the one rule, the claim's own file with no other claim, the spec gate family); the claim-surface leg is a ③ flag.

The first-non-blank-character change (^\s* plus [^$\s]), now at outputVariable / errorVariable too — a correct narrowing. Two executors trim before binding: screen-nodes.ts:233 (idVariable) and :393 (the script outputVariable?.trim()), so ' $id' passed the first-character rule and bound $id — measured. Every other executor binds the string verbatim (crud-nodes.ts:500/514/566, subflow-node.ts:96, map-node.ts:172-173, loop-node.ts:127-128, try-catch-node.ts:105/275, logic-nodes.ts:202-205), so a blank-led name there bound a literal blank-led name that no {{ }} hole can read: refusing it refuses nothing a run can bind and read. The pattern refuses a string exactly when its trim() starts with $ — evaluated on 21 inputs including NBSP and U+2028, which \s and String.prototype.trim treat alike. No false refusal found: x, a$b, ' x', x$, a line break after the first character, the empty and the blank string all parse (empty and blank keep the old answer; the loop iterator's .min(1) still refuses empty, map-node.ts:111 still falls back to item); errorVariable: '$error' and the default are kept (pinned). The engine's names $record, $runId, $loopItems, $ are refused at every site — right, since binding over them is the measured wrong run. One residual, not a defect and unchanged from #22502's rule: errorVariable: ' $error' is admitted (its trimmed form is the engine's own name) while try_catch binds the literal; the catch region still reads $error because the engine sets it itself (engine.ts:11407, :11496).

The enumeration / discovery pin — right, with its limit stated. It walks z.toJSONSchema of every Zod export of automation/index.ts for any property spelled *Variable at any depth: a new such key without the rule publishes no pattern and turns red; one with the rule but outside FLOW_BINDING_KEYS turns red; a vocabulary key without a BINDING_SITES row turns red (the vocabulary pin holds the table equal to FLOW_BINDING_KEYS); a floor refuses a vacuous walk. A binding spelled otherwise (a field's name, a variable's name, an assignments key) is hand-listed and pinned in the published JSON too — the test and the PR body say so. The api ScreenSpec.idVariable is a response shape outside automation/index.ts, correctly outside the rule. The dev's two ablations (map indexVariable rule dropped → 4 red including the discovery pin; the leading-blank half dropped → 18 red) are consistent with the pin's mechanics.

The ADR-0087 kit — right on this head. 18.flow-binding-name-dollar-refused.ts matches the precedent's shape: surface names every position (the test pins six substrings), no backticks and no pipes in it, replacement and acceptanceCriteria present, no conversionIds (no D2 — the bare name may already be bound, the reads sit in every dialect; the precedent's reasoning). registry.ts is regenerated and carries the hand-written STEP18_RATIONALE fragment at order: 93; joinRationale sorts by order, so it follows #22502's 92 as its prose assumes. The four regenerated reference pages carry the new .describe() strings verbatim (FlowVariable.name appears in flow.mdx twice and in automation-api.mdx once because the api reference embeds the flow's variable shape). The published json-schema/** is untracked (json-schema.manifest lists names only), so no tracked schema output moves. Against the check-runs: none exist. At the reading above the head has 0 check-runs and 0 workflow runs; five app check-suites (vercel, fly-io, claude, cloudflare, objectstack-fleet) sit queued with zero runs and there is no github-actions suite at all; the one commit status is Vercel = success. Nothing has concluded, so nothing is judged on CI here; the dev's gate union (114 derived, 112 run, all exit 0, check:generated current, check:api-surface green, check:authorable-surface green) is a local reading, not CI. See ③.

Public surface: no new export — flowBoundVariableNameSchema, FLOW_BINDING_KEYS, flowBoundVariableNameRefusal and flowAssignmentTargets live in the package-internal leaf absent from automation/index.ts; the TypeScript types of every key are unchanged (a regex check moves no type). authorable-defaults/automation.json unchanged. Right.

② Semver level

  • .changeset/22572-flow-binding-name-dollar-refused.md: @objectstack/spec at major, **BREAKING** in the body, Clause-②: no (narrowing), exactly one adr-0087: registered flow-binding-name-dollar-refused disposition marker in the gate's HTML-comment syntax, FROM → TO table and the one-line fix, the who-is-affected measurement (35 example flows byte-identical before and after; AST scan of examples, platform-objects, dogfood, the rest of packages and hotcrm; the pinned objectui) — right, and byte-for-byte the shape of spec(automation): try_catch's errorVariable and a node's outputVariable accept a $-named variable that a flow text slot now refuses to read (two doors of one contract disagree after #22477) #22502's changeset. .changeset/pre.json is mode: pre, tag: next, so the release is 18.0.0-next.*, as the body says.
  • The level is consistent with AGENTS.md: (narrowing) is BREAKING, so major in pre mode; Clause-②: no because no new export or accepted input is added.
  • Every publishing package is covered: the only package touched is packages/spec; content/docs/** and .changeset/ publish nothing. The D3 id resolves at HEAD through the entry file (and through registry.ts at the merge base), so check-adr-0087-registration reads [major+BREAKING+clause-②-narrowing] registered ... (new here) as the body claims.

③ Boundary flags

Each deviation and out-of-scope finding the ACCEPT 6103439893 records, answered or escalated:

  1. ScreenFieldConfigSchema.name in-place addition — answered in ① (same class, in scope, measured). Escalated: the claim 6102582908 was never edited (created_at equals updated_at), so the review checklist's leg "the claim's file surface revised in the same round" is unmet for this addition and for dropped-refinements.baseline.json. The dispatching seat owes a claim amendment (an edit or a serial note on the card naming both) before enqueue; it is a process debt, not a contract defect.
  2. First-non-blank judgement at outputVariable / errorVariable — answered in ①: a correct narrowing of spec(automation): try_catch's errorVariable and a node's outputVariable accept a $-named variable that a flow text slot now refuses to read (two doors of one contract disagree after #22477) #22502's rule, still unreleased on the same 18.0.0-next line, named in the D3 entry's surface and the changeset. The ' $error' residual is noted, no action.
  3. dropped-refinements.baseline.json — answered: forced by build-schemas.ts, the right site (out on automation/AssignmentConfig), total 704 → 705. Accepted.
  4. PR assignee via label-write rather than pr_create — no bearing on the contract; one write either way. Noted.
  5. The safety-check refusal on /sa-suite.pid — a stray on the dev container's filesystem root, outside the repository; not worked around; nothing in the diff. Escalated as housekeeping for a person (rm /sa-suite.pid on that container), no bearing on this PR.
  6. The ADR-0087 marker in HTML-comment syntax — right; scripts/check-adr-0087-registration.mjs reads exactly that form in a repo file.

Out-of-scope findings, each confirmed at the merge base and left noted as the ACCEPT says: the executor configSchema descriptors still type the binding keys as plain strings (crud-nodes.ts:448/540, map-node.ts:78/81/88, loop-node.ts:52-53, screen-nodes.ts:146, try-catch-node.ts:75) — designer-form hints; the contract parse refuses regardless, and this PR touches no service-automation file; the buildSubflowResumeSignal comment; content/docs/automation/flows.mdx naming only outputVariable beside errorVariable — hand-written, still true, outside the surface; a body-less legacy loop's iteratorVariable unjudged at the flow door — right, its executor sets $loopItems / $loopIndex itself (loop-node.ts:77-78) and never reads the key.

New flags from this review, escalated to the dispatching seat:

  1. The head does not merge into origin/main. mergeable_state is dirty: ed1de8c2db (build(spec): the migration registry is generated at build and leaves git #22706, landed after the merge base) deleted packages/spec/src/migrations/registry.ts from git (generated at install and build) and moved the hand-written STEP18_RATIONALE to registry.ts.template; this PR modifies the deleted file, a modify/delete conflict. Required before enqueue, exactly as seat 1's note 6101550727 and the claim foresaw: re-sync through scripts/pm/os-regen-merge.sh, drop registry.ts from the PR, add the order: 93 fragment to registry.ts.template (sorted by key, before flow-binding-variable-dollar-name-refused), keep the entry file. The rationale test then reads the generated registry. That re-sync adds a hand-written template fragment, so it is not a pure regeneration: the new head owes its own record, and this one stays bound to af752544fb.
  2. No CI on this head. The relay's opened event produced no github-actions check-suite; the head commit predates the PR, so no synchronize has run either. The landing precondition "every check green" is unmet until the re-sync push raises one. Nothing here is judged on CI.
  3. needs:contract-review marker — the PR carries no labels although the ACCEPT found the Clause-② arm with no record on the head; this record answers it for af752544fb, and the re-synced head will need it hung again per the marker protocol. A marker, not a gate.

Implemented-by: claude/issue-22572-dollar-binding-keys
Reviewed-by: session_01KNKBCRDJCu5tGy3TEbvtrF

VERDICT: PASS


Generated by Claude Code

…llar-binding-keys

Resolved by hand to the #22706 model: packages/spec/src/migrations/registry.ts
leaves git (deleted on main, generated at build); the order-93
STEP18_RATIONALE fragment for flow-binding-name-dollar-refused moves into
packages/spec/src/migrations/registry.ts.template, sorted by key before
flow-binding-variable-dollar-name-refused. The entry file stays.

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 11, 2026
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 35 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json, packages/spec/src/migrations/registry.ts.template), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/flows.mdx (via FlowSchema (symbol, a top-level const), errorVariable (symbol, a field of const object BINDING_CLAUSES), idVariable (symbol, a field of const object BINDING_CLAUSES), indexVariable (symbol, a field of const object BINDING_CLAUSES), iteratorVariable (symbol, a field of const object BINDING_CLAUSES), outputVariable (symbol, a field of const object BINDING_CLAUSES), errorVariable (literal, a string literal in FlowBindingKey), idVariable (literal, a string literal in ScreenConfigSchema), indexVariable (literal, a string literal in LoopConfigSchema; a string literal in MapConfigSchema), iteratorVariable (literal, a string literal in LoopConfigSchema; a string literal in MapConfigSchema), outputVariable (literal, a string literal in FlowBindingKey))
  • content/docs/kernel/runtime-services/examples.mdx (via outputVariable (symbol, a field of const object BINDING_CLAUSES), outputVariable (literal, a string literal in FlowBindingKey))

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

  • content/docs/releases/v17/17-0.mdx (via FlowSchema (symbol, a top-level const), FlowVariableSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-4.mdx (via FlowSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-7.mdx (via FlowSchema (symbol, a top-level const))

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

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json, packages/spec/src/migrations/registry.ts.template) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 139 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 e84aeb36ce14169a633670f14ce8280fc998e2b9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e5f49596a4d4d5f155dbf307de8901ef2914a6d6 — the merge of head 7b175fa88b17b4f12020832308cdacf93a57d0cc into base e84aeb36ce14169a633670f14ce8280fc998e2b9, 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 e5f49596a4d4d5f155dbf307de8901ef2914a6d6 && git checkout e5f49596a4d4d5f155dbf307de8901ef2914a6d6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e84aeb36ce14169a633670f14ce8280fc998e2b9 7b175fa88b17b4f12020832308cdacf93a57d0cc && git checkout -B drift-repro e84aeb36ce14169a633670f14ce8280fc998e2b9 && git merge --no-ff 7b175fa88b17b4f12020832308cdacf93a57d0cc

node scripts/docs-audit/affected-docs.mjs --json e84aeb36ce14169a633670f14ce8280fc998e2b9

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e84aeb36ce14169a633670f14ce8280fc998e2b9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

TypeScript Type Check is red on e8c40b9c23, and the red is main's (#22744), not this PR's. The fix is PR #22750, accepted and landing

domain:spec seat 3 (#18883) · zhuangjianguo · session session_01KNKBCRDJCu5tGy3TEbvtrF · 2026-10-11T00:45Z · holder of claim 6102582908 on #22572.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e8c40b9c23b6f9a9cd1cd507889f96859ce5caf2
Local-runs: none

Round 2, scoped to the sync hop. Round 1 (6103539398) holds ① ② ③ at af752544fb; this record judges only what moved: the one merge of origin/main ed1de8c2db (#22706) into af752544fb, its hand-resolved modify/delete on registry.ts, and the CI that now exists on the head. Read read-only on the two trees (git diff-tree, per-path blob ids, cmp on the fragment), card #22572 (the seat's sync order 6103554833, both os-dev-reports, the ACCEPT 6103439893, the amended claim 6102582908), PR #22746 (round 1, the seat's CI note 6103889988, the net diff against ed1de8c2db), #22706's diff, #22744's body, #22750's diff, and the head's check-runs plus three job logs through the API, read at 2026-10-11T06:14Z. The seat's conclusions were inputs to contradict, not to adopt.

① Derived judgments

1. The resolution — right, and exactly the fragment round 1 judged.

  • registry.ts leaves the PR: absent from the head tree and from main's; the head's .gitignore (main's blob) ignores it at line 68; .gitattributes at the head is main's, so nothing routes it to the regen driver any more.
  • registry.ts.template differs from main's blob (bd49126d8c → e11bbaf60a) by one hunk, +16 / −0 at line 1846: the STEP18_RATIONALE element id: 'flow-binding-name-dollar-refused', order: 93. Those 16 lines are byte-identical (same sha1, cmp clean) to lines 5669–5684 of af752544fb's registry.ts, the fragment round 1 read.
  • Placement: between flow-approval-node-config-contract-refused (order: 84) and flow-binding-variable-dollar-name-refused (order: 92) on both sides. The template's 116 STEP18_RATIONALE ids are in code-unit order with the new one in place (checked with a sort over the extracted id list, not by eye), which is what step18-rationale-merge.test.ts pins; joinRationale sorts by order then id, so 93 renders last, after spec(automation): try_catch's errorVariable and a node's outputVariable accept a $-named variable that a flow text slot now refuses to read (two doors of one contract disagree after #22477) #22502's 92, as the prose "And every other binding does too, closing the family" assumes. order: 93 is unique; the highest on main is 92.
  • The generated registry carries the entry. The generator at the head is main's bytes (build-migration-registry.ts, blob 37c5871176): it concatenates entries/semantic/*.ts sorted by id into the template's os-generated semantic:18 region (template lines 3174–3175), and refuses an entry whose filename is not shardNameFor(major, id) or whose id: line it cannot parse. 18.flow-binding-name-dollar-refused.ts is unchanged from round 1 (blob 74fd67e41b); its name equals shardNameFor(18, 'flow-binding-name-dollar-refused'), its id: property sits on its own line in the parser's shape, and its initializer ends in };. In CI, not locally: every Test Core shard's install printed packages/spec prepare: wrote src/migrations/registry.ts (420 semantic, 258 retired-key, 222 retired-def) — main has 419 — and shard 1/6 ran src/automation/flow-bound-variable-name.test.ts (115 tests, green), whose last describe reads MIGRATIONS_BY_MAJOR[18].semantic for exactly one entry with this id, its surface naming all six positions, no conversionIds, and the step's rationale containing the id; the same shard ran scripts/step18-rationale-merge.test.ts (9 tests, green), the sorted-by-key pin, now over the template. Check Changeset (green) runs check-adr-0087-registration.mjs --base MERGE_BASE (pr-automation.yml:980), which resolves the D3 id from the entry file through the rev's own generation.

2. Nothing else moved — right, proved per blob, not per diffstat.

  • git diff-tree af752544fb e8c40b9c23 lists exactly build(spec): the migration registry is generated at build and leaves git #22706's 25 paths, no 26th. For 23 of them the head's blob id equals ed1de8c2db's; the 24th is registry.ts (deleted on both); the 25th is the template, whose only delta is the fragment above.
  • git diff-tree ed1de8c2db e8c40b9c23 lists the PR's 13 paths; 12 carry the blob id they had at af752544fb (the changeset, the four regenerated reference pages, dropped-refinements.baseline.json, the four automation schema and rule files, the test, the entry file); the 13th is the template. The numstat is round 1's line for line, with registry.ts +81 replaced by registry.ts.template +16 (the other 65 lines were the generated region, which now lives in no tracked file). GitHub's file list agrees: 13 files, +706 / −69.
  • No conversion, no retired-key or retired-def entry, no api-surface shard, no authorable-defaults change: as in round 1.

3. The red TypeScript Type Check — main's (#22744), independent of this diff.

  • What is red: the aggregate job fails at its only step, "Verify every type-check lane succeeded", because Type Check · source gates (job 114345266117) failed at step 24, "Render the generated spec-changes and upgrade-guide diff against the base" (render-projection-diff.ts --base HEAD^1). Steps 1–23 are success, among them 18 Type check (@objectstack/spec), 22 check:spec-changes and 23 check:upgrade-guide: the head side of the same two projections generates. Type Check · workspace, · consumer gates and · debt ledger are success.
  • Signature, read from the job log: projection generation failed: base HEAD^1 = ed1de8c2db spec-changes.json: build-spec-changes.ts exited 1, the same for protocol-upgrade-guide.md, each with Error: Cannot find module '../src/migrations/registry' thrown from the archive's packages/spec/scripts/.
  • Why it is not this PR's: the script's header says the base side is "ref's own generators, run in a git archive of ref". That archive is main at ed1de8c2db and contains no byte of this PR. Since build(spec): the migration registry is generated at build and leaves git #22706 it carries registry.ts.template and entries/ but no registry.ts, and nothing in it generates one, so main's own build-spec-changes.ts and build-upgrade-guide.ts throw — ci(spec): render-projection-diff's base archive lacks the generated registry.ts since #22706, so every merge-queue entry fails Type Check · source gates #22744's site. This PR's 13 paths include none of render-projection-diff.ts, the two base generators or the workflow. The same signature — step 24, the same first error line with a different base sha (8b5b2c3f77) — is on merge-group run 38093887970 (PR fix(core): an import row for a sandboxed body the door answers as a fault reads as that fault #22740, head 3b07cbe6cf, 2026-10-10T23:06Z, before this head existed), on a PR that touches no packages/spec file; read from that job's log, not from the seat's note.
  • Where the review rule's exception falls short, said plainly: "a red with the same signature at the merge-base does not count" is judged on the base's own check-runs, and ed1de8c2db's own Type Check · source gates runs are green (its HEAD^1, f59a73c395, still committed the file). The base cannot show this signature; only its children can. So the exemption here rests on the mechanism and on the merge-group reading above, with ci(spec): render-projection-diff's base archive lacks the generated registry.ts since #22706, so every merge-queue entry fails Type Check · source gates #22744 as the filed main-red card, not on a literal base-side match.
  • The fix and its proof: fix(spec): render-projection-diff generates a base's git-ignored migration registry #22750 (render-projection-diff.ts +48 / −3, its test +110; generateBaseRegistry runs the base's own generator inside the archive when the registry is absent and the generator present) merged as 052a5e153c at 2026-10-11T02:15Z. Its merge-group run 38102516312 has Type Check · source gates success on a base (d7b26df5b5) that, like this PR's, commits no registry. After the seat's next merge of main, this step will generate the base's registry and render a NON-EMPTY projection diff (one new record for flow-binding-name-dollar-refused); the script's header and lint.yml:5632 say a diff is never red, only a side that cannot generate.
  • Every other required context on the head is success: Lint & Repo Gates, Test Core (6/6), Dogfood Regression Gate (3/3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. 35 check-runs: 31 success, 2 skipped by design (Console Pin Gate, Packed-tarball smoke (opt-in)), 2 failure (the lane and its aggregate).

4. Round 1's judgments stand. The accept-set narrowings (eight sites, the first-non-blank rule, the FlowSchema assignment arm), the enumeration pin, the public surface (no new export) and the ADR-0087 kit were judged on blobs that are byte-identical at this head; nothing in the hop re-opens them. The regenerated reference pages are the same four blobs, and main has not touched them, or any of the 13 paths, in its 22 commits since ed1de8c2db.

② Semver level

Unchanged from round 1, on the same bytes: .changeset/22572-flow-binding-name-dollar-refused.md (blob 90723dcd1e) declares @objectstack/spec major in pre mode (18.0.0-next), Clause-②: no (narrowing), one registered flow-binding-name-dollar-refused disposition marker, the FROM → TO table and the one-line fix. The hop adds no publishing change: registry.ts.template is in no files[] glob of packages/spec and no src/ file imports it; the generated registry.ts sits at the path the committed one had, so the bundle's module graph is unchanged (#22706's own measurement, outside this hop). Check Changeset on the head is green, and its ADR-0087 step resolves the id from the entry file (new here, as the dev's local run also read). Level consistent with AGENTS.md: (narrowing) is BREAKING, so major.

③ Boundary flags

Round 1's flags, re-read on this head:

  1. Flag 7 (the head did not merge) — cleared. mergeable: true, mergeable_state: blocked (awaiting review and checks, not dirty) against main at e84aeb36ce. main since ed1de8c2db touches none of the PR's 13 paths; its one new step-18 entry (18.position-permission-sets-declared.ts) is a different filename in a directory whose listing is the index, so no add/add. The next merge is textually clean on this PR's paths; GitHub's reading is the authority.
  2. Flag 8 (no CI) — cleared: 35 check-runs on the head.
  3. Flag 1 (claim never amended) — cleared: claim 6102582908 was edited in place (2026-10-11T00:01Z) and now names ScreenFieldConfigSchema.name, dropped-refinements.baseline.json, the first-non-blank rule and registry.ts.template.
  4. Flag 9 (needs:contract-review marker) — still not hung; the PR's labels are documentation, size/l, tests, tooling. A marker, not a gate; this record removes nothing because nothing was hung.
  5. Flag 5 (/sa-suite.pid) — still on the dev container's filesystem root per the second report; for a person, unchanged.

Dev-report items this round:

  1. The dev's out-of-scope finding (class a: the red lane) — it is ci(spec): render-projection-diff's base archive lacks the generated registry.ts since #22706, so every merge-queue entry fails Type Check · source gates #22744, filed by domain:spec seat 1 before the dev's report and fixed by fix(spec): render-projection-diff generates a base's git-ignored migration registry #22750; no second card. Answered.
  2. No ablation this round — right: the hop adds no rule and moves hand-written prose only; the pins that would catch a dropped fragment or entry (the registry describe in the rule's test, the sorted-key pin) ran green in CI on this head.
  3. The dev's bare -- deviation (a vitest launch that would have dropped its argument, stopped and relaunched) — no bearing on the contract; only the relaunch is counted.

New flags, escalated to the dispatching seat:

  1. Sixteen source-gate steps are unmeasured in CI on this head. Steps 25–41 of Type Check · source gates were skipped behind step 24 — among them check:docs (the generated reference pages this PR regenerates), the authorable key surface, check:skill-refs, check:future-spec-major, check:react-blocks, the template manifests and llms.txt. The dev's check:generated at e8c40b9c23 ("14 up to date") and the 109-of-111 green gate union are local readings; round 1 saw the same source bytes locally green too. This PASS does not stand in for "every check green": the next head, after the merge of 052a5e153c, is where these steps first run in CI, and the landing precondition reads that head's check-runs, not this record.
  2. The next hop must be pure to inherit this record. The seat's pure-hop check must show the PR's 13 blobs unchanged (the template's delta against main still the one fragment) and every other path at main's bytes — the same two diff-tree readings as in ① 2. A merge that touches the template beyond the fragment, or re-resolves anything by hand, owes a new record.
  3. The projection diff becomes visible for the first time on the next head. render-projection-diff will render the D3 record this PR adds to spec-changes.json and the upgrade guide into the job summary; nobody has read that rendered diff yet, because it could not generate. The seat reads it on the next head's run; it is the [Decision] generated protocol-change records (spec-changes.json per-major section, docs/protocol-upgrade-guide.md): keep committing them, or generate them at publish like the release section #22449 B′ condition (1) artefact this review's inputs could not include.

Implemented-by: claude/issue-22572-dollar-binding-keys
Reviewed-by: session_01KNKBCRDJCu5tGy3TEbvtrF

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Landing pre-checks at 7b175fa88b, by the owning seat: all green, queued

domain:spec seat 3 (#18883) · zhuangjianguo · session session_01KNKBCRDJCu5tGy3TEbvtrF · 2026-10-11T07:03Z · holder of claim 6102582908 on #22572.

  • The review:

    • Contract review round 1, PASS 6103539398 at af752544fb.
    • Round 2, scoped to the first sync hop: PASS 6106153207 at e8c40b9c23.
    • The second hop to 7b175fa88b is a merge of origin/main e84aeb36ce alone. It is pure, checked mechanically, so it inherits round 2 (its flag 10):
      • unexplainedPathsBetween(e8c40b9c23, 7b175fa88b) is empty;
      • the PR's 13 paths are blob-equal to e8c40b9c23;
      • the net diff against main is the same 13 files, +706 / −69.

    The seat's ACCEPT is 6103439893; the sync order is 6106162985.

  • CI: 35 check-runs, 33 success and 2 skipped. check-expected-skips --pr 22746 reads both skips in the roster: Console Pin Gate and Packed-tarball smoke (opt-in).

    • Round 2's flag 9: Type Check · source gates ran all 45 of its steps green, including the 16 that were skipped behind the red on e8c40b9c23.
    • Round 2's flag 11: the projection diff rendered spec-changes.json +14 −0 and protocol-upgrade-guide.md +4 −1. That is one new migrated record on 18, flow-binding-name-dollar-refused, and nothing else.
  • Governed: check-governed-merges --pr objectstack-ai/objectstack#22746 reads NOT governed: 0 of 13 paths on the register, +706 / −69.

  • Closing keywords: the body carries Fixes #22572 alone, and no commit message carries one.

  • main drift since the merge base e84aeb36ce: main (now a2e94c2a05) moved none of the PR's 13 paths and no generated or regenerated path. Landing rule A owes no sync. GitHub reports mergeable: true, clean.

pr_ready and automerge_enable follow.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 07:04
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 07:04
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 9f5eca5 Oct 11, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22572-dollar-binding-keys branch October 11, 2026 07:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants