Skip to content

feat(spec): assignment values may be a CEL value envelope — the expression ledger gains the value role - #15113

Merged
zhuangjianguo merged 8 commits into
mainfrom
claude/issue-14149-assignment-value-role
Sep 4, 2026
Merged

zhuangjianguo merged 8 commits into
mainfrom
claude/issue-14149-assignment-value-role

Conversation

@claude

@claude claude Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #14149

Clause ②: yes — path limb: every edited source path is under packages/spec/src/** (automation/flow-node-expression-paths.ts, automation/builtin-node-config.zod.ts, automation/node-executor.zod.ts plus their pins; node scripts/pm/dispatch-gates.mjs --tier at the final head: "no path-derived mandate", each path ⇢ the contract surface); content limb yes: a published node schema widens what it accepts — an assignment value may now be a CEL value envelope beside {token} interpolation — and the exported FlowNodeExpressionRole union widens by value.

Maintainer ruling 2026-09-02 (card comment 5507504961, verbatim reply 「同意」 to recommendation A): the rendering half only — FLOW_NODE_EXPRESSION_PATHS gains a value role for the assignment node, no new vocabulary, the grouping half deferred, script stays the workaround until A lands. This PR is the SPEC half of that ruling, exactly as dispatched by the domain:spec seat (card comment 5532987091 / 5533002333), plus the ONE file the seat widened the surface by in its ruling on the first report's blocker — card comment 5533840057 (2026-09-04T00:18Z, option A with A2, same PR): the ledger's reconciliation ratchet packages/services/service-automation/src/builtin/config-expression-ledger.test.ts. Nothing else under packages/services/service-automation/src/** is touched; the executor half is reported below, not implemented. Final head after the last commit: d25a6606 — origin/main (f594e70d) merged in by the merge commit 7ed4fb54 (scripts/pm/os-regen-merge.sh, no rebase, no amend, no force-push) and the one artifact changed on both sides since the merge base, the generated docs/audits/2026-07-unknown-key-strictness-ledger.counts.md, regenerated from the merged tree with pnpm --filter @objectstack/spec gen:strictness-ledger in d25a6606 (the os-regen marker cleared on that commit). The commits after d902edfd keep their wip/chore titles — no history rewrite.

What changed

  • packages/spec/src/automation/flow-node-expression-paths.ts
    • FlowNodeExpressionRole gains 'value', with a docblock: a slot whose authored value may be a { dialect: 'cel', source } expression envelope evaluated by the expression engine to the value the variable takes — not a predicate, not a template; the slot's SHAPE decides the dialect (a plain string stays {token} interpolation, an envelope is CEL).
    • FLOW_NODE_EXPRESSION_PATHS gains one entry: { nodeType: 'assignment', path: 'assignments.*', role: 'value', label: 'assignment value' }.
    • Path spelling: a * segment means "every own key of this object" — the sibling of [] ("every element of this array"). The resolver understood only [] before, so this is the smallest extension: one new branch in walk() guarded by head === '*'; every existing entry resolves byte-identically because no pre-existing path carries * and no pre-existing entry has the value role (pinned with the ratchet's own fixtures restated in flow-node-expression-paths.test.ts).
    • resolveFlowNodeExpressions emits, for a value entry, only envelope-shaped objects; for the two existing roles the rule is unchanged (non-empty strings). isExpressionEnvelopeShaped (a plain object with a string dialect) is exported as the one recognizer both halves discriminate on — deliberately looser than "a valid envelope", so { dialect: 'cel' } with no source reaches the validator and is refused there instead of being stored as a literal.
    • The declaration the ratchet reads (A2): AssignmentConfigSchema is exposed through the JSON map getSchemalessNodeConfigJsonSchemas() — the walk the ratchet already performs — by a NEW sibling map LEDGER_DECLARED_NODE_CONFIG_SCHEMAS in schemaless-node-config.zod.ts, deliberately not by adding assignment to SCHEMALESS_NODE_CONFIG_SCHEMAS: that const means "publishes no descriptor" and packages/metadata-protocol/src/reference-sites.ts:480 walks it for exactly that reason (limb 2 of reference-site attribution), so its key set and its readers stay untouched. Exported types LedgerDeclaredNodeType / ReconciledNodeConfigType; the function's return type widens accordingly. Pinned spec-side: SCHEMALESS_NODE_CONFIG_SCHEMAS keys are still exactly decision / script / subflow, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS keys are exactly assignment, and the projected assignment JSON carries properties.assignments.additionalProperties.xExpression === 'value'.
    • Why assignments.* and only that: the assignment node's canonical config is the assignments map its descriptor (logic-nodes.ts, additionalProperties: true) and the Studio keyValue editor declare, and every bundled example writes. The two legacy shapes the executor also normalizes — the bare { variable: value } config and the assignments: [{ variable, value }] array — are read-compatibility the descriptor does not declare and the form↔Zod ledger pins as "not offered for new authoring"; their values keep today's meaning (an envelope-shaped object there is the literal it always was). Declaring a top-level * entry would also misread config.assignments itself as a value slot whenever an author names a variable dialect.
  • packages/spec/src/automation/builtin-node-config.zod.ts
    • AssignmentExpressionValueSchema = ExpressionSchema.safeExtend({ dialect: z.literal('cel') }) — the canonical { dialect, source, ast?, meta? } envelope from shared/expression.zod.ts (the spelling validateExpression already reads: packages/formula/src/validate.ts, toSource()), with its own source-or-ast refinement kept and the dialect narrowed at the type level. Not a second spelling. .extend() is refused by Zod on a refined object; safeExtend is the sanctioned form — measured.
    • AssignmentValueSchema — z.unknown() refined: anything that is not envelope-shaped passes untouched (strings, scalars, arrays, plain objects — the preservation half); an envelope-shaped value must parse as the CEL envelope, else a custom issue at the value's path whose message starts with the fixed sentence ASSIGNMENT_VALUE_ENVELOPE_REFUSAL. Carries .meta({ xExpression: 'value' }) — the ledger's declaration channel for this slot; measured to ride z.toJSONSchema onto the map's additionalProperties (pinned). Built eagerly rather than through lazySchema: .meta() registers by schema identity and the lazy Proxy is not that identity, so a lazily wrapped marker never reaches the JSON Schema (measured; the sibling markers all sit on eager inner schemas).
    • AssignmentConfigSchema — { assignments?: Record(name → AssignmentValueSchema) } with an open catchall for the bare legacy keys; the array form is refused as a type error on the record carrying ASSIGNMENT_ARRAY_FORM_PRESCRIPTION (an object-level refinement never runs once a property failed its type — Zod aborts the object; measured). Plus the ADR-0122 …Parsed aliases.
    • Docblock: assignment moves from "deliberately absent" to "described by its VALUES, not by a key set".
  • packages/spec/src/automation/node-executor.zod.ts — the configSchema docblock names the third role and its declaration channel.
  • Pins: flow-node-expression-paths.test.ts (new, 17 cases) and a new describe in builtin-node-config.test.ts (10 cases): the entry and role; resolution on a two-variable fixture (owner_name: '{manager.name}', digest: envelope) yields exactly assignments.digest with the envelope verbatim; {token} strings and literals skipped; a malformed envelope still resolved; legacy shapes resolve nothing; the ratchet's condition pin kept; every pre-existing entry byte-identical; envelope accepted (the ruling's joinNonEmpty(overdue_tasks.map(t, t.subject), "\n") source, verbatim); interpolation and every other literal still accepted; malformed envelopes refused with code + path + the leading sentence (no source, empty source, non-string source, unknown / template / cron dialect); the array form's prescription; the xExpression: 'value' marker in the JSON Schema.
  • Docs: content/docs/automation/flows.mdx gains an "Assignment (set variables)" example with both forms and the digest, plus a warn callout stating the runtime status honestly (the executor half has not landed; a script node is the workaround today). Generated: content/docs/references/automation/builtin-node-config.mdx, api-surface/, export-origins/, declaration-map/, authorable-surface/, json-schema.manifest/ (automation shards), the strictness ledger counts.
  • .changeset/assignment-value-role-cel-envelope.md — @objectstack/spec minor.

Measured, not edited

  • Validator role table: validateExpression's roles live in packages/formula/src/validate.ts:41 — FieldRole = 'predicate' | 'value' | 'template' — so a value role already exists there (line 553 onward: "predicate | value → CEL"). Nothing in packages/spec holds a role table; the ledger's FlowNodeExpressionRole is the declared-and-pinned side. Probed on the built engine at this head: validateExpression('value', { dialect: 'cel', source: DIGEST }) → ok: true; a template envelope → "expected a CEL expression but got a template dialect."; celEngine.evaluate({ dialect: 'cel', source: DIGEST }, { extra: vars, record: vars }) → "Renew contract\nCall ACME" for three rows with one empty subject — the map macro and joinNonEmpty both work, so the ruling's example is a real digest body. One seam for the executor half: validateExpression('value', { dialect: 'cel' }) (no source) answers ok: true — a missing source reads as "not authored" there — so AssignmentConfigSchema is the only gate that refuses { dialect: 'cel' }, and the executor half must parse the config against it (the parseNodeConfig seam) before validating.
  • Liveness: pnpm --filter @objectstack/spec check:liveness green; the gate is registry-rooted over metadata types at one-level granularity and flow.nodes.config carries a single blanket live verdict (liveness/flow.json, props.nodes.children.config), so node-config values are not walked and no planned entry is owed; the executor pointer sits in the role's docblock.
  • Downstream (prefix direction, after building the spec): @objectstack/lint typecheck → 0 and src/validate-expressions.test.ts green (its ledger consumer guards with role !== 'predicate', not an exhaustive switch). @objectstack/service-automation has NO typecheck script (ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT); tsc --noEmit -p tsconfig.json in the package → 3 errors, all src/nested-region-parity.test.ts (Property 'flows' is private), byte-identical on origin/main, none naming the role union — pre-existing test-layer debt, not this change.

The widening — seat ruling 5533840057, one test file

packages/services/service-automation/src/builtin/config-expression-ledger.test.ts (the #4027 ratchet) is the only edit outside the declared surface, on the ruled four-condition adjacent-fix path. What it gains, and what it does not:

  • ROLE_BY_MARKER.value = 'value' — a third row beside expression → predicate and template → flow-template, with the docblock stating that it IS validateExpression's value role and what makes it a distinct ledger role (the slot's shape rule).
  • collectExpressionProps descends an object-valued additionalProperties as a * segment — the sibling of items → [], the same spelling the spec resolver gained. A bare additionalProperties: true (the assignment DESCRIPTOR's own shape) declares nothing, so the descriptor channel and the spec channel cannot double-declare the slot.
  • A positive pin: assignment.assignments.* is in the ledger with role value, reaches the ratchet through the schemaless-config JSON channel specifically and NOT through the descriptor channel, and the marker is mapped (ROLE_BY_MARKER.value === 'value'). The existing pins are untouched, including the line-270 pin (assignment with a bare condition resolves nothing).
  • Loosened: nothing. The two refusals the ratchet exists for were re-proven live at this head, dist-resolved (the test imports @objectstack/spec/automation through dist/, so each leg rebuilt spec with OS_SKIP_DTS=1 and proved the mutation reached dist/ with scripts/ablation-dist-preflight.mjs; source restored by git checkout HEAD -- ABS_PATH and proven by blob hash + empty git diff HEAD; a final FULL build restored dist/):
    • stale entry — assignment removed from LEDGER_DECLARED_NODE_CONFIG_SCHEMAS (source occurrences 0, dist preflight --absent exit 0) → Tests 2 failed | 10 passed (12): the ledger carries no path a builtin no longer declares with the verdict stale ledger entries — no descriptor or schemaless schema declares these: expected [ 'assignment.assignments.* (value)' ] to deeply equal [], plus the new positive pin;
    • undeclared marker — xExpression: 'value' → 'valeu' on the map value (injected count 1, dist preflight present exit 0) → Tests 5 failed | 7 passed (12): every xExpression property a builtin declares is in the ledger, the schemaless-channel derivation, the stale check, the 表达式 ledger 只认 descriptor configSchema,所以「刻意 schemaless」的节点上声明的 CEL 槽位没有任何 build-time 校验器 #4439 decision pin and the new pin, with the verdict assignment.assignments.* declares an unknown xExpression marker 'valeu' — add it to ROLE_BY_MARKER and teach the validators which dialect it takes;
    • control after both restores and the full rebuild: Tests 12 passed (12).
  • Before / after at the final head: at d902edfd (first report) the file ran Test Files 1 failed (1) / Tests 1 failed | 10 passed (11) — the stale-entries assertion; at 11e61c06 it runs Test Files 1 passed (1) / Tests 12 passed (12) (the full file, every describe).
  • No service-automation changeset: a test-only edit publishes no bytes. The spec minor changeset stays.

Executor half (read-and-report, packages/services/service-automation)

  • src/engine.ts:6900-6901 (in validateFlowExpressions, the ledger consumer): for (const found of resolveFlowNodeExpressions(node.type, node.config)) { if (found.entry.role !== 'predicate') continue; … — every non-predicate slot is resolved and skipped, so the new value entry is walked and ignored at registerFlow; nothing calls validateExpression('value', …).
  • src/builtin/logic-nodes.ts:112-137 (the assignment executor): normalizes the three shapes to pairs and runs variables.set(key, interpolate(value, variables, context)) (line 135).
  • src/builtin/template.ts:346-368 (interpolate): for an object it recurses into every property and returns a copy — so an envelope { dialect: 'cel', source: '…' } is written into the variable VERBATIM as a literal object (its source string gets {token} interpolation on the way, so a CEL map literal inside it would even be mangled), never evaluated; notify-node.ts:259-260 then renders {digest} through stringifyForTemplate → the JSON text of the envelope. The executor half therefore needs: evaluate envelope-shaped values in the canonical map through the expression engine ({ extra: vars, record: vars } is the context shape engine.ts:7757 already uses), parse the config against AssignmentConfigSchema at execute time, check value slots with validateExpression('value', …) at registerFlow (and the lint pass packages/lint/src/validate-expressions.ts:1138, another lane), and extend the ratchet as above.

Studio form (read-and-report, objectui origin/main e304a4ef)

  • packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts:475-479: assignment: [cfg('assignments', 'Assignments', 'keyValue', …)] — the descriptor's free-form map rendered by FlowKeyValueField.tsx.
  • FlowKeyValueField.tsx:47-63 parseValue: a value cell that starts with { and ends with } is JSON.parsed on commit (arrays likewise), non-JSON falls through to a plain string, and toRaw renders a stored object back as its JSON text — so the form ALREADY accepts a non-string value and an author CAN type {"dialect":"cel","source":"joinNonEmpty(...)"} and have it stored as the envelope object. What it lacks is an affordance: no per-row "CEL value" toggle or CEL editor, no validateExpression('value', …) feedback (the FlowExprIssue it imports is for template strings), and the simulator (previews/simulator/flow-simulator.ts:352-360, interpolateValue) returns a non-string value as-is, mirroring the runtime's verbatim write. json-schema-to-fields.ts:243-254 reads xExpression only on string properties ('expression' | 'template'), so the new marker on additionalProperties changes no editor and breaks nothing. Verdict: usable without an editor change; a consumer-side card is warranted for the affordance (a per-row envelope toggle + value-role validation + simulator evaluation), not required for the contract.

Verification record at the merged head d25a6606

Exits captured by redirect; verdict lines the gates' own. pnpm --filter @objectstack/spec check:generated --fix on the merged tree regenerated nothing (✓ All 15 generated artifacts are up to date.), so the head did not move after the ledger commit.

  • Ratchet file packages/services/service-automation/src/builtin/config-expression-ledger.test.ts → Test Files 1 passed (1) / Tests 12 passed (12).
  • The three spec pin files → Test Files 3 passed (3) / Tests 87 passed (87).
  • pnpm --filter @objectstack/spec build → exit 0; pnpm --filter @objectstack/spec typecheck → exit 0.
  • check:generated → ✓ All 15 generated artifacts are up to date.; check:strictness-ledger → ✓ docs/audits/2026-07-unknown-key-strictness-ledger.counts.md is current — 440 site(s) measured, 1 authorable strip site(s) left.
  • node scripts/check-system-context-census.mjs → OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read.
  • No behaviour changed by the merge round; the schema is as reviewed (the legacy assignments: [{ variable, value }] array-form refusal noted by the director's audit, card comment 5534406255, is left for the executor-half card the seat files at ACCEPT).

Verification record at the pre-merge head 11e61c06

Every exit captured by redirect before any read; the verdict lines quoted are the gates' own. Order of events at this head: the union chunks and the locked spec runs first on the quiescent committed tree; then the two dist-resolved ratchet legs above (which rebuild spec); then a final full pnpm --filter @objectstack/spec build (exit 0) and, on that rebuilt dist, the dist-reading gates again.

  • Spec pins (3 files, after the final rebuild): Test Files 3 passed (3) / Tests 87 passed (87).
  • Ratchet file (after the final rebuild): Test Files 1 passed (1) / Tests 12 passed (12).
  • pnpm --filter @objectstack/spec build (the final full build, after the legs) → exit 0; pnpm --filter @objectstack/spec typecheck (after it) → exit 0; pnpm --filter @objectstack/lint typecheck → exit 0.
  • check:generated after the final rebuild → ✓ All 15 generated artifacts are up to date. (check:generated --fix before the last commit regenerated api-surface/ and export-origins/ for the three new automation exports and nothing else).
  • Full spec suite under the shared verify lock at this head: Test Files 467 passed (467) / Tests 12463 passed (12463).
  • node scripts/check-system-context-census.mjs after the last edit → OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read.
  • pnpm exec eslint --no-inline-config over the seven edited TypeScript files (six spec, the ratchet) → exit 0.
  • Union from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at this head: 92 commands (the 90 derived at the previous head plus node scripts/check-tenant-audit-census.mjs and its --self-test, both exit 0). Run one by one on the quiescent tree: 89 exit 0 — check:skill-examples after building the @objectstack/client-react closure it reads (✅ 256 prose examples type-check across 3 surface(s)); 3 answer NOT MEASURED by their own verdict — node scripts/check-test-completeness.mjs (exit 3, PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named), pnpm check:dual-build-cjs-loads (exit 3, PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/), pnpm check:type-check-debt (exit 3, PREREQUISITE NOT MET — the whole-workspace built closure is CI's; check:type-check-coverage inside it reports OK — 72/79 workspace packages type-checked). After the final rebuild the dist-reading members were re-run and are 0 again: check:generated, check:api-surface, check:export-origins, check:docs, check:authorable-surface, check:liveness, check:exported-any, check:dual-source-exports, check:entry-nameability, check:browser-reachable-entries, check:strictness-ledger, check:role-word, check:spec-parsed-alias.

Record at the previous head d902edfd (the two files the reverse-verification legs below mutate changed only in comment lines since — git diff d902edfd 11e61c06 on them shows no code line — so those legs stand):

  • Pins (spec, 3 files): Test Files 3 passed (3) / Tests 86 passed (86) (87 at 11e61c06, one new channel pin).
  • pnpm --filter @objectstack/spec build → exit 0 (DTS emitted; check-dts-emitted and check-dev-prereqs --stamp passed inside it).
  • pnpm --filter @objectstack/spec typecheck → exit 0 (tsc, scripts program, test program — the test files are inside tsconfig.test.json's src/** include, so the new pins are type-checked).
  • pnpm --filter @objectstack/spec check:generated --fix then check:generated → ✓ All 15 generated artifacts are up to date.
  • Full spec suite, vitest run --maxWorkers=2 under the shared verify lock: Test Files 467 passed (467) / Tests 12462 passed (12462).
  • check:authorable-surface, check:liveness, check:docs, check:api-surface, check:export-origins, check:strictness-ledger → exit 0 (inside the union below).
  • pnpm exec eslint --no-inline-config over the five edited TypeScript files → exit 0.
  • node scripts/check-system-context-census.mjs after the last edit → OK — 106 elevation read sites in 20 packages across 45 files, all anchored.
  • pnpm check:role-word → 0 and pnpm check:spec-parsed-alias → OK after the two findings they raised on an earlier head were fixed in d902edfd (ADR-0090 reserved word in the flows page; ADR-0122 …Parsed aliases).
  • Union from node scripts/pm/dispatch-gates.mjs --commands (no paths passed; derived from the merge base at this head): 90 commands run one by one; 87 exit 0 (check:skill-examples after building the @objectstack/client-react closure it reads: ✅ 256 prose examples type-check across 3 surface(s)); 3 answer NOT MEASURED by their own verdict — node scripts/check-test-completeness.mjs (exit 3, PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named), pnpm check:dual-build-cjs-loads (exit 3, PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/), pnpm check:type-check-debt (exit 3, PREREQUISITE NOT MET — the whole-workspace built closure is CI's; check:type-check-coverage inside it reports OK — 72/79 workspace packages type-checked).
  • Downstream consumers as measured above; the ratchet red at that head is the "before" reading of the widening section.

Reverse verification (from the committed state, restore by blob hash)

Script with trap restore EXIT INT TERM, absolute paths under git rev-parse --show-toplevel; the pins import the modules under test by relative source specifier, so no dist/ is involved and a mutation is live the moment it is on disk.

  • Leg 1 — the assignment ledger entry deleted: on-disk proof grep -c "path: 'assignments.*'" = 0 (expected 0), git diff --stat 1 file / 23 deletions → pins 6 failed | 53 passed (59) (the entry, the role union, the two-variable resolution, the malformed-envelope resolution, the authoring-order resolution, the five-entry ledger pin). Restore: git checkout HEAD -- FILE; git hash-object = 500b8a57… = HEAD: blob; git diff HEAD empty.
  • Leg 2 — envelope acceptance removed (the if (result.success) return; arm replaced by an unconditional refusal; injected marker count 1, original anchor count 0, git diff --stat 1 file / +1 −1) → pins 3 failed | 56 passed (59) (the accept pin with the ruling's example, the two-forms pin, the "wherever it sits" pin whose green key now reds). Restore proven the same way: blob 8a5c42d2… = HEAD:; git diff HEAD empty.
  • Control on the restored tree: 59 passed (59).

Notes for review

  • No consumer-side fallback anywhere: the envelope is refused at authoring when malformed, and a plain string keeps its one meaning.
  • The bare and array legacy shapes are documented as read-compatibility, not made contract (Prime Directive Add comprehensive test suite for Zod schema validation #12); no ADR-0087 entry is added for them because nothing here retires them.
  • check-dev-prereqs.mjs at this head still names 32 unbuilt app packages — the dev-server precondition, not a gate in the union.
  • Still untouched, per the ruling: packages/services/service-automation/src/** outside the ratchet test (logic-nodes.ts included — the descriptor keeps additionalProperties: true and the form↔Zod ledger's pin on it is untouched), packages/formula/src/validate.ts, objectui.

🤖 Generated with Claude Code

https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i


Generated by Claude Code

…ontract, pins, docs, changeset

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…Schema; array-form prescription on the record type error

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…sion ledger gains the value role

FLOW_NODE_EXPRESSION_PATHS gains the assignment entry at assignments.* with the
new value role; ledger paths accept a * segment (every key of an object, the
sibling of []); resolveFlowNodeExpressions emits only envelope-shaped objects
for a value slot, so a plain string stays {token} interpolation and every
pre-existing entry resolves byte-identically. AssignmentConfigSchema /
AssignmentValueSchema / AssignmentExpressionValueSchema declare the assignment
node's value contract (ExpressionSchema, safeExtended to the cel dialect,
beside interpolation and literals); a malformed envelope is refused at the
variable's path with a fixed leading sentence; the map value carries
xExpression: value as the ledger's declaration channel. Docs page, generated
artifacts, and a spec minor changeset. The executor half (evaluation,
validateExpression value checks, the reconciliation ratchet's marker table)
is service-automation's and is reported, not edited.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…R-0090 reserved word in the flows page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…ledger ratchet; ratchet learns the value marker and the additionalProperties walk

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…-declared node config map

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…rged tree

The os-regen merge driver deferred docs/audits/2026-07-unknown-key-strictness-ledger.counts.md (changed on both sides since the merge base); regenerated with pnpm --filter @objectstack/spec gen:strictness-ledger on the merged tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 23 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/authorable-surface/automation.json, packages/spec/declaration-map/automation.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/flows.mdx (via AssignmentConfigSchema (symbol, a top-level const))
What this run could not see
  • 5 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/authorable-surface/automation.json, packages/spec/declaration-map/automation.json, …) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 128 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 f594e70d796cbdd8e751c30105ede566acfec584 → packageMentionDocs.

Which tree this was computed on

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

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

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

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Contract review (Clause ②) — PASS · ACCEPT

Reviewer of record: the domain:spec PM seat, session session_0174WZTU6XcFcS7g2kykC53i (seat post #6017). Tier fuse: get_session read at 2026-09-04T02:07Z — both model fields equal CONTRACT_REVIEW_TIER. Gate: check-clause2-carriers.mjs --pair 15113 exit 0 at 02:08Z (the Clause-②: declaration is in the dev's own claim comment 5533016185, edited in place at 00:26Z with the widened file; both carriers hung). Reports read against GitHub and the fetched branch: 5533585464-era round 1 (00:14Z, blocked), 5534552929 (01:53Z, head 11e61c06), 5534651321 (02:07Z, merged head d25a6606). The director's audit of the contract (5534406255, hotlong, summon 13, fuse 01:26Z) is read as the audit reading it says it is; this review owns the release.

Head and window (readings at 2026-09-04T02:09Z)

  • Merged head d25a6606 = PR head.sha = branch tip. It is the merge commit 7ed4fb54 (origin/main f594e70d into the branch, via os-regen-merge.sh) plus one regeneration commit for the artifact the os-regen driver deferred: git diff origin/main...d25a6606 is byte-identical to git diff 29db3cd2...11e61c06 apart from docs/audits/2026-07-unknown-key-strictness-ledger.counts.md (7/7 lines, gen:strictness-ledger from the merged tree; check:strictness-ledger "current — 440 site(s) measured"). No behaviour moved in the merge round.
  • Trial merge onto origin/main 2cc46103: clean. Governed test: 0 of 17 changed files. No content/docs/releases/ edits. H17 index: no hit.
  • CI on d25a6606 at 02:08Z: 34 checks — 22 success, 2 skipped, 10 running, 0 red (Build Core, Check Changeset, Type Check · debt ledger, Temporal Conformance already green). Landing waits for all-green.

The widening, verified in the diff

  • Spec contract (ruling A, rendering half, no new vocabulary): FlowNodeExpressionRole gains 'value'; FLOW_NODE_EXPRESSION_PATHS gains { assignment, 'assignments.*', value }; the resolver's walk gains the * segment as the sibling of [] (an array under * resolves nothing rather than inventing index paths) and resolveFlowNodeExpressions emits only envelope-shaped objects for a value slot (a plain string stays {token} interpolation). isExpressionEnvelopeShaped is the one exported recognizer (plain object with a string dialect, deliberately looser than "valid" so a malformed envelope reaches the validator). Every pre-existing entry resolves byte-identically — pinned with the ratchet's own fixtures restated.
  • The value contract: AssignmentExpressionValueSchema = ExpressionSchema.safeExtend({ dialect: z.literal('cel') }) — the existing envelope spelling narrowed, not a second one; AssignmentValueSchema = z.unknown().superRefine that refuses only an envelope-shaped value that fails the envelope schema, at the value's path, code: 'custom', leading with the fixed sentence ASSIGNMENT_VALUE_ENVELOPE_REFUSAL; .meta({ xExpression: 'value' }) on the map value is the ledger's declaration channel (built eagerly — .meta registers by identity, measured). AssignmentConfigSchema = { assignments?: record(string.min(1), AssignmentValueSchema) } + catchall(unknown), not strict (the descriptor's additionalProperties: true pin stands); the legacy array form is refused on the record's own invalid_type with ASSIGNMENT_ARRAY_FORM_PRESCRIPTION.
  • Preservation, pinned: strings, scalars, arrays, plain objects and bare legacy top-level keys still parse; an envelope-shaped object under a bare legacy key is a literal as it always was; the two shipped shell forms coexist by shape, never by a mode key.
  • The one widening the ruling did not speak to (the director's flag, concurred): AssignmentConfigSchema refuses assignments: [{ variable, value }] while the executor still normalizes it; nothing parses a node config against this schema today, so no flow changes behaviour on this PR. The refuse-or-accept decision at registerFlow is stated on the domain:services executor-half card this seat files at ACCEPT (below) — it must be settled before AssignmentConfigSchema is wired into parseNodeConfig. Recorded as a flag, not a blocker.
  • The A2 widening (seat ruling 5533840057): exactly one file outside the declared surface — packages/services/service-automation/src/builtin/config-expression-ledger.test.ts (+43/−1): ROLE_BY_MARKER.value, collectExpressionProps descends an object-valued additionalProperties as * (a bare additionalProperties: true declares nothing, so the descriptor and spec channels cannot double-declare), one positive pin for assignment.assignments.* (declared through the schemaless-config JSON channel, not the descriptor), the line-270 pin untouched. The declaration lands spec-side through the new LEDGER_DECLARED_NODE_CONFIG_SCHEMAS map in schemaless-node-config.zod.ts — kept apart from SCHEMALESS_NODE_CONFIG_SCHEMAS because that map's meaning ("publishes no descriptor") has other readers (metadata-protocol's reference-site attribution); its key set and readers are untouched and pinned. logic-nodes.ts, builtin-node-form-zod-ledger.test.ts, packages/formula/src/validate.ts (the FieldRole table already includes 'value'), objectui: untouched. Four-condition check: same defect class (ledger + its ratchet are one contract), mechanical (the ratchet's failure text prescribes it), file claimed by no one (0 of 24 open PRs), same gate family (Test Core); the claim's file surface was revised in place and the PR body names the widening with before/after readings. Passes.
  • Ratchet semantics kept, proven live: at d902edfd 1 failed | 10 passed; at the head 12 passed (12); ablations from the committed state, dist-resolved with the preflight: a stale entry → 2 failed | 10 passed with the stale-entries verdict, an unknown marker valeu → 5 failed | 7 passed with the roleOf verdict; restores by blob hash; control 12/12.

Report checklist

  • Reverse verification (round 1, still standing — git diff d902edfd 11e61c06 on the two mutated files is comment-only): ledger entry deleted → 6 failed | 53 passed; envelope acceptance replaced by an unconditional refusal → 3 failed | 56 passed; restores proven by git hash-object = HEAD: blob, control 59/59. Direction red, as predicted.
  • Engine probe on built formula dist: validateExpression('value', envelope) ok; a template envelope refused "expected a CEL expression but got a template dialect"; celEngine.evaluate renders the ruling's joinNonEmpty example.
  • Gate readings at the merged head: ratchet 12/12; spec pins 87/87; spec build + typecheck 0; check:generated all 15 (nothing beyond the ledger commit); check:strictness-ledger current; census OK. At 11e61c06: full spec suite 12463/12463; lint typecheck 0; eslint over the seven edited TS files 0; dispatch-gates --commands 92 run, 89 exit 0, NOT MEASURED by their own verdicts: test-completeness (saved log), dual-build CJS loads (no dist), type-check debt (unbuilt deps; coverage 72/79). check:role-word and check:spec-parsed-alias raised real findings on an earlier head and were fixed — the kind of catch the gates exist for.
  • Changeset: @objectstack/spec published, minor — a new role, new exports (isExpressionEnvelopeShaped, AssignmentConfigSchema family, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS), an accept-set widening; the ADR-0087 marker is not owed (no authorable key retired). check:liveness measured not to govern node-config values (flow.nodes.config is one blanket live verdict) — no planned entry owed. No service-automation changeset: a test-only edit publishes no bytes.
  • Docs: content/docs/automation/flows.mdx gains the worked example with an honest runtime-status callout (the executor half is not landed); the generated references and references/index.mdx follow. Docs-drift advisory: one hand-written page, flows.mdx — edited.
  • Scope: 17 files = the declared surface + the A2 file + generated followers + the ledger regen + docs + changeset; no content/docs/releases/. Closing keyword, two reads (02:08Z): first line Fixes #14149; no other closing keyword beside a card number (#4027, #4439 are citations). Fixes is correct — the card is the spec half as dispatched; the halves below are new cards.
  • MCP-call accounting: 0 across three rounds. Head pinning: the report's head = the PR head. Housekeeping: wip / chore commit titles remain (no history rewrite — the squash composes the landing message from the PR).
  • Read-and-report accepted: executor (logic-nodes.ts:135 via template.ts:346-368 writes the envelope verbatim; notify renders it as JSON; engine.ts:6901 skips non-predicate slots); objectui FlowKeyValueField parseValue :47-63 JSON-parses a {…} cell (storable, no affordance); json-schema-to-fields reads xExpression on string properties only.

Follow-up cards this seat files at landing

  1. domain:services — the executor half: evaluate a value-role envelope in the assignment executor (validateExpression('value', …) at registerFlow / objectstack validate, evaluation at run time), notify rendering, and the ruling on the legacy assignments: [{ variable, value }] array form (refuse at registerFlow or accept as untyped legacy) before AssignmentConfigSchema is wired into parseNodeConfig. Blocked-by: #14149 until this PR is MERGED.
  2. objectui — the Studio assignment form affordance for the envelope value (FlowKeyValueField; the inspector reads xExpression on string properties only). Consumer-side, release-gated.

Landing

At all-green on d25a6606: provenance → ready → squash auto-merge → added_to_merge_queue. On MERGED: strip pm:dispatched, probe flow-node-expression-paths.ts / builtin-node-config.zod.ts on origin/main, landing note, the two cards above filed.

needs:contract-review is cleared on this PR and on #14149 in this stroke (read-modify-write, both read back). If the head moves after this clear, the gate's C3 reading re-hangs it and the delta is reviewed before it is cleared again.


Generated by Claude Code

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Landing provenance — domain:spec PM seat, session session_0174WZTU6XcFcS7g2kykC53i (seat post #6017). Contract review (Clause ②) PASS · ACCEPT: comment 5534669909 (02:09Z); needs:contract-review cleared on both carriers 02:09Z; director audit 5534406255 concurs.

Landing window readings, all on head d25a6606 at 2026-09-04T02:24Z:

  • CI: all green — {('completed', 'success'): 36, ('completed', 'skipped'): 4} (0 red, 0 running); the required floor (Lint & Repo Gates, Type Check · workspace / · consumer gates / · debt ledger, Check Changeset, Build Core, Temporal Conformance (live PG + MySQL)) all success on this head — the CJS-load reading (check:dual-build-cjs-loads in Build Core) and the ratchet in Test Core included.
  • Trial merge onto origin/main 2cc46103: clean.
  • check-clause2-carriers.mjs --pair 15113: exit 0 (head unchanged since the clear — no C3). Governed surface: 0 of 17 changed files. No content/docs/releases/ edits. H17 index: no hit.
  • Closing keyword, two reads: first line Fixes #14149; no other closing keyword beside a card number in the body.

Sequence: ready → squash auto-merge → added_to_merge_queue. On MERGED: strip pm:dispatched from #14149, probe flow-node-expression-paths.ts / builtin-node-config.zod.ts on origin/main, landing note on the card, the domain:services executor-half card and the objectui Studio-affordance card filed.


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 4, 2026 02:24
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit f3bbbef Sep 4, 2026
42 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14149-assignment-value-role branch September 4, 2026 02:52
os-warren pushed a commit that referenced this pull request Sep 4, 2026
…ssignment executor and validate it at registerFlow (#15137)

The executor half of the maintainer's 2026-09-02 ruling on #14149, whose spec
half landed in PR #15113. An `assignment` value that is an `{ dialect: 'cel',
source }` envelope is now evaluated by the expression engine and the result
assigned; before this it went to `interpolate()`, which recursed into it as a
plain object and wrote it into the variable verbatim, so `notify` rendered
`{"dialect":"cel","source":"…"}` as JSON and the declared CEL stdlib was
unreachable from metadata.

Three parts:

- `AutomationEngine.evaluateValueEnvelope` evaluates a declared envelope in the
  CEL scope `evaluateCondition` already builds — extracted to one shared
  `celScope`, so a predicate and a value expression cannot disagree about what a
  variable means. A refusal throws with the source attached (ADR-0032 §1c/§1d):
  a value that failed to compute has no falsy default to hide behind.
- `registerFlow` refuses a malformed envelope (throws) and `objectstack validate`
  reports it as a located finding — the severity split the `predicate` role
  already uses. Both compose the SAME two published primitives, in the same
  order: `AssignmentValueSchema` for shape (it alone catches the source-less
  `{ dialect: 'cel' }` that `validateExpression` reads as "not authored") and
  `validateExpression('value', …)` for CEL. One notion of malformed, derived
  once, so registration and evaluation cannot refuse different sets.
- The behaviour is scoped to the slot the ledger declares, `assignments.*`. The
  legacy `assignments: [{ variable, value }]` array and the bare
  `{ <variable>: <value> }` config keep every meaning they had, envelope-shaped
  values included: `AssignmentConfigSchema` is deliberately NOT wired into
  `parseNodeConfig` for the array form, because refusing it would break flows
  that register today and that refusal is a ruling, not a lane's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 9, 2026
…ssignment executor and validate it at registerFlow (objectstack-ai#15137) (objectstack-ai#15432)

* feat(service-automation): evaluate a value-role CEL envelope in the assignment executor and validate it at registerFlow (objectstack-ai#15137)

The executor half of the maintainer's 2026-09-02 ruling on objectstack-ai#14149, whose spec
half landed in PR objectstack-ai#15113. An `assignment` value that is an `{ dialect: 'cel',
source }` envelope is now evaluated by the expression engine and the result
assigned; before this it went to `interpolate()`, which recursed into it as a
plain object and wrote it into the variable verbatim, so `notify` rendered
`{"dialect":"cel","source":"…"}` as JSON and the declared CEL stdlib was
unreachable from metadata.

Three parts:

- `AutomationEngine.evaluateValueEnvelope` evaluates a declared envelope in the
  CEL scope `evaluateCondition` already builds — extracted to one shared
  `celScope`, so a predicate and a value expression cannot disagree about what a
  variable means. A refusal throws with the source attached (ADR-0032 §1c/§1d):
  a value that failed to compute has no falsy default to hide behind.
- `registerFlow` refuses a malformed envelope (throws) and `objectstack validate`
  reports it as a located finding — the severity split the `predicate` role
  already uses. Both compose the SAME two published primitives, in the same
  order: `AssignmentValueSchema` for shape (it alone catches the source-less
  `{ dialect: 'cel' }` that `validateExpression` reads as "not authored") and
  `validateExpression('value', …)` for CEL. One notion of malformed, derived
  once, so registration and evaluation cannot refuse different sets.
- The behaviour is scoped to the slot the ledger declares, `assignments.*`. The
  legacy `assignments: [{ variable, value }]` array and the bare
  `{ <variable>: <value> }` config keep every meaning they had, envelope-shaped
  values included: `AssignmentConfigSchema` is deliberately NOT wired into
  `parseNodeConfig` for the array form, because refusing it would break flows
  that register today and that refusal is a ruling, not a lane's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y

* fix(service-automation): clear the ADR-0087 gate, correct the docs this landing falsified, and pin the bound of the reject-set property (objectstack-ai#15137)

The four items from the Clause-② contract review on PR objectstack-ai#15432. The mechanism is
untouched — no change to engine.ts, logic-nodes.ts or validate-expressions.ts.

- **ADR-0087 gate (red, required).** The changeset declares BREAKING and carried
  no disposition marker, so `check-adr-0087-registration.mjs` failed. It now
  carries `not-required (no-migration-prescription)`: no authorable key is
  renamed, retired or re-typed, so `objectstack migrate meta` has nothing to
  rewrite. The gate was missed locally because `dispatch-gates.mjs` presents
  `--self-test` as the runnable member and files the real `--base` invocation as
  not-runnable, although the script's own usage line defaults `--base` to
  `origin/main`.
- **Docs this PR made false.** `content/docs/automation/flows.mdx` still told
  authors the executor writes the envelope verbatim and prescribed a `script`
  node "for a digest body today". Replaced with what landed: where a malformed
  envelope is refused (three doors, one composition) and which two shapes fault
  at run time instead. The same stale sentence in
  `packages/spec/src/automation/flow-node-expression-paths.ts` is corrected as a
  COMMENT — no contract, schema, export or generated artifact moves.
- **The property's bound, pinned.** `{ dialect: 'cel', source: '   ' }`
  registers and then faults: `source` is `z.string().min(1)` so whitespace
  passes the shape rule, `validateExpression` trims to empty and answers "not
  authored", and the CEL engine parses it untrimmed. Same seam class as the
  `ast`-only case. Pinned loud on both sides (engine faults, lint is silent) and
  added to objectstack-ai#15430 — deliberately NOT closed here with a trim rule of the
  engine's own, which would be the third locally-invented notion of "malformed"
  this design exists to prevent.
- **Accuracy.** "Exactly four things" was an under-count (`source: 5` and
  `meta: 'bad'` are refused too); the reject set is stated as the composition.
  The runtime publish gate is named as the third door —
  `validateStackExpressions` is registered `CLI_AND_RUNTIME` / `['flow']`
  (`authoring-rules.ts:431-441`), so Studio / REST / MCP flow writes get the
  same refusal at `severity: 'error'`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…n in words instead of a tracker number (stage 30) (objectstack-ai#22414)

Part of objectstack-ai#20749
Clause-②: no

Stage 30 of this card: the class (e) remainder, the test strings shipped
under the `packages/spec/src` subdirectories, as ruled in `5902360492`
on objectstack-ai#20513. The census at the base reads 17 messages / 18 ids in 12
files. This stage rewrites 7 of them (6 titles and 1 expect message, 8
ids) in 5 files: each now states what its record decided, or drops the
number where the title already says it. The other 10 messages / 10 ids
stay, 4 because earlier stages decided they are not citations and 6
because an assertion matches the string against text this claim does not
let the stage edit; both groups are named under "What stays". Text only:
no assertion, identifier, test count, code comment, file name or
non-test file changes. No file is deferred.

## Census (re-taken first)

The instrument is stage 28's `census28.cjs`, byte-identical (md5
`31d8488b5194b8d3048e3fcaec0efaed`, the value stages 28 and 29
published): an AST walk over the `packages/spec/src` test files, one
message per folded string (a lone literal, a template, or a plus chain)
that matches the gate's id pattern, a title when the folded root is
argument 0 of a describe / it / test / suite / bench call, comments
never read. It was run against the three published readings before it
was trusted, and all three reproduce exactly: 128 messages / 130 ids in
37 files at `f7b8a5932b`, 191 / 200 in 53 files at `aa09db58c9`, 73 / 76
in 24 files at `b7e01fbbd`.

| reading | messages / ids | files |
|:--|--:|--:|
| base `11d119ab1` | 17 / 18 (titles 6 / 7, other 11 / 11) | 12 |
| stage 29's landing `0ef9029da` | 17 / 18, per file equal to the base |
12 |
| this head | 10 / 10 (titles 0 / 0, other 10 / 10) | 7 |
| the 5 edited files, this head | 0 / 0 | 0 of 5 |

Stage 29's ACCEPT carried 16 / 17 (ui 9 / 9 in 7 files, automation 2 /
3, ai 2 / 2, api 1 / 1, contracts 1 / 1, kernel 1 / 1). The base reads 1
/ 1 more, all in `ui`: the title `carries no ruling date and no tracker
id (objectstack-ai#22093)` at `view-submit-redirect-url.test.ts:341`, which PR objectstack-ai#22322
(`ad381fd94`, landed 2026-10-09T00:35Z) added after stage 29's head. So
`ui` reads 10 / 10 in 7 files, and nothing else moved. The census at
`origin/main` `e75dceddd` (8 commits past the base, none touching the 12
files) reads the same 17 / 18 in the same 12 files, so nothing regrew
while this stage ran. The reading is within one message of the claim's,
so there was no re-cut.

Per file, messages at base: `ai/build-progress` 2,
`api/meta-item-response-shapes` 1, `automation/builtin-node-config` 2 (3
ids), `contracts/approval-service` 1, `kernel/manifest` 1,
`ui/action-description` 1, `ui/component-props-unknown-members.pin` 1,
`ui/dashboard-chart-structure-refusal` 2, `ui/dashboard` 2,
`ui/notification` 1, `ui/strictness-batch14` 1,
`ui/view-submit-redirect-url` 2.

Controls:
- Pathspec: the 12 named paths hit the control word `describe(` in 12 of
12 files and a nonsense word in none; the census scanned 12 of 12.
- Planted, in a scratch tree: an id in a describe title, a plus-chain
title, an expect message, a template literal, a cross-repo spelling and
a ledger-style string each read once (6 / 6); a comment, a six-digit
colour, an HTML entity, a two-digit number and a hex colour with a
letter read 0.
- Lit and dark inside the group: the 5 edited files read 1, 2, 1, 1 and
2 messages at base and 0 at the head; the 7 untouched files read the
same at both ends.

## Deferral

At the census (2026-10-09T03:03Z) 17 PRs were open; at the re-scan
before opening this PR (04:13Z), 13. Every file list was read through
REST (605 and 593 rows). None touches any of the 12 files: lit control
`api/protocol.test.ts` (PR objectstack-ai#22323) found, dark control 0. Of the four
PRs the claim named, objectstack-ai#22380 has landed and objectstack-ai#22315, objectstack-ai#22323 and objectstack-ai#22215 are
open; none of them touches a file in this group. Deferred files: none.

## What changed

7 literals, one line each, in 5 files: +7 / -7. Every file keeps its
line count.
- `api/meta-item-response-shapes.test.ts:226`: the `[objectstack-ai#22114] ` prefix
goes; the title already says what objectstack-ai#22126 landed, that the read serves
the version token and the 409 carries the current one as data.
- `automation/builtin-node-config.test.ts:434`: "a CEL envelope beside
literals; the `{token}` dialect retired". objectstack-ai#14149's ruling A made an
assignment value a CEL envelope beside literals, and objectstack-ai#19939 retires the
`{token}` dialect in flow value slots; the title already stated both, so
only the two numbers go.
- `automation/builtin-node-config.test.ts:485`: the `[objectstack-ai#19939] ` prefix
goes from the REFUSES title.
- `kernel/manifest.test.ts:681` and `ui/action-description.test.ts:235`:
the trailing `(objectstack-ai#22093)` goes. objectstack-ai#22093 decided that author-visible help
and refusals carry no service-interface name, ruling date or foreign
example id, and both titles already say what their bodies pin.
- `ui/view-submit-redirect-url.test.ts:341`: the trailing `(objectstack-ai#22093)`
goes from the title.
- `ui/view-submit-redirect-url.test.ts:118`: the expect message `states
the rule, not its ruling date (objectstack-ai#22093)` drops the number. It is an
assertion's failure message, so it was needle-checked first (below) and
is the one declared non-title string.

All seven are "drop a number the title already explains". None needed a
rewrite in new words, because each title already carried the decision.

## What stays, and why

10 messages / 10 ids in 7 files, none edited.

Four are CSS hex colours, not citations. `colors: ['objectstack-ai#111', 'objectstack-ai#222']` at
`ui/dashboard-chart-structure-refusal.test.ts:94` and `palette: ['objectstack-ai#111',
'objectstack-ai#222']` at `ui/dashboard.test.ts:124` are fixture input the schema
under test reads. Stage 21's ACCEPT (`6001279159`, decision A) kept them
by file and line, and every later stage carried them forward.

Six are strings that an assertion matches against text outside this
stage's edit surface. Moving one at the same strength means editing a
non-test source docblock (and, for the first two, its generated
reference page) or the assertion that matches it. The claim forbids both
and says to stop and report, so none is touched; `open_questions` in the
report carries the decision.
- `ai/build-progress.test.ts:236` `'cloud#2172'` and `:237`
`'objectui#7388 block 2'`: `toContain` over the source text of
`ai/build-progress.zod.ts` (docblock lines 8, 27 and 85), which
`content/docs/references/ai/build-progress.mdx` renders.
- `contracts/approval-service.test.ts:274` `'objectstack-ai#16495'`: `toContain` over
the docblock above `continueRestoredRun` in
`contracts/approval-service.ts` (line 999).
- `ui/notification.test.ts:123` `'// [objectstack-ai#4610]'`: the locator of the
tombstone note in `ui/notification.zod.ts:94`; the file's own
`toMatch(/^\[objectstack-ai#4610\]/)` at `:134` reads the same note.
- `ui/strictness-batch14.test.ts:395` `'objectstack-ai#5015'`: `toContain` over
`ui/notification.zod.ts` and `ui/sharing.zod.ts`.
- `ui/component-props-unknown-members.pin.test.ts:322` `ruling:
'decision card objectstack-ai#21704, fork 4, letter B (record 5979239990)'`: the
file's own assertion at `:417` matches the value with `/objectstack-ai#21704/`. Stage
20's ACCEPT (`5998488373`) kept it for this reason and sent it to the
needles' stage.

Readers of the seven rewritten strings: none. `git grep -F` at HEAD over
the tracked tree outside the 12 files, with the full literal, a
24-character window around each id, and the text on each side of each id
(29 needles over all 17 sites): the only hits are the readers of the
kept strings named above, the lit control (`composeStacks` in
`stack.zod.ts`) hits and the dark control does not. The same needles
searched inside the 12 files, outside each literal's own span: the only
hits are two code comments beside `:322`. The five short needles
(`cloud#2172`, `objectstack-ai#16495`, `// [objectstack-ai#4610]`, `objectstack-ai#5015`, `objectstack-ai#21704`) fall under the
script's 12-character floor, so their readers were confirmed by direct
`git grep -F` with a dark control.

## Cited records

Read with their comments as the API serves them: objectstack-ai#22114 (8 of 8
comments; landed as PR objectstack-ai#22126), objectstack-ai#14149 (12 of 12; ruling A `5507504961`,
landed as PR objectstack-ai#15113), objectstack-ai#19939 (15 of 15; pass 1 landed as PR objectstack-ai#22259, the
card stays open), objectstack-ai#22093 (17 of 17; PRs objectstack-ai#22125, objectstack-ai#22309 and objectstack-ai#22322), and
the four PRs themselves. objectstack-ai#19939 is still open: its pass 1 refuses the
`{token}` dialect in flow value slots and keeps two spellings (the date
macros and `{$User.*}`) until CEL can write them. The describe's
PRESERVATION test still accepts those two, and the title keeps the
record's own verb, "retired", as PR objectstack-ai#22259 wrote it. The title and the
test body say the same thing the record says.

## Verification

At head `8885dbf1c` (one commit on base `11d119ab1`):
- **Text only.** `textonly28.cjs` (stage 28's, md5
`957eff6b3837d762b8e03d070155930a`) on all 12 base copies against their
heads: 12 / 12 SAME. 7 changed tokens, as predicted in writing at
2026-10-09T03:08Z before any edit or test run: 6 titles and 1 declared
string (`--declared 118`). Every other string token, identifier, number,
punctuation mark and comment is byte-equal. 16 controls, expectations
written in the script before the first run, 16 / 16 as predicted: an
identifier rename, a numeric literal, a comment edit, an undeclared
expect message, a rewritten title given a new id, an id-free title
edited, one title reverted to base (SAME, 0 changed), a declared label
without `--declared`, a declared line plus another changed string, the
declared line alone (SAME, 2 changed), a title re-split into a plus
chain, a test added, an untouched file (SAME, 0 changed), an id appended
to a rewritten title, a kept needle rewritten, a kept hex colour
rewritten. The two controls that mutate a string beside the declared
line fail at the mutated line, not at 118.
- **Tests, 12 files, base and head.** `--project local --project repo`
with the JSON reporter, 618 tests in 88 suites each side, all passed.
Per-file test count and status sequence identical in 12 / 12. 23 full
names changed (4 + 14 + 1 + 3 + 1), 0 mismatches against the plan. Names
carrying `#` plus digits: 23 at base, 0 at head. Duplicate full names: 3
and 3, the same three `[object Object]` it.each rows at both ends.
- **Full spec unit tier at the head**, under the verify lock: `Test
Files 626 passed (626)`, `Tests 18743 passed | 1 todo (18744)`.
- **Build and typecheck**, under the verify lock: `turbo run build` over
`packages/*` and `packages/*/*`, `Tasks: 71 successful, 71 total`;
`@objectstack/spec` `typecheck` exit 0 with `check:test-typecheck`
holding 52 files / 246 errors / 135 pinned signatures, the same figures
as stage 29; the 12 files are all in the `tsconfig.test.json` program.
- **Gates.** `dispatch-gates.mjs --commands` at the head derives 79
(stage 29's 77 plus `check:authorable-surface` and
`check:yaml-examples`); all 79 exit 0, and `--ran` reconciles 79
derived, 79 run, 0 NOT-MEASURED. The five artifact-roster families that
keep their roster in a directory one of the paths is in
(`check:meta-url-spelling`, `check:spec-changes`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`) and `check:generated` (all 15 artifacts up
to date) also exit 0.
- **ESLint**, `--no-inline-config`, 12 files: 0 errors, 0 warnings.
Population from ESLint's own config: 12 configured, 0 ignored, 0 with a
type-aware parser option, so this diff cannot move the verdict of a file
it does not touch.
- **Skip-changeset.** `npm pack --dry-run --json --ignore-scripts` in
`packages/spec`: 2069 files, 0 `*.test.ts`, 0 of the 5 edited files;
controls `src/stack.zod.ts`, `dist/index.mjs` and `package.json`
present. The rewritten expect message occurs in 0 files of `dist/`; the
control `Unrecognized key` occurs in 42. Nothing published changes.
- **Governed.** `check-governed-merges.mjs --test` on the 5 paths: 0 of
5, not governed; 14 changed lines.
- **Merge.** `git merge-tree --write-tree` onto `origin/main`
`e75dceddd`: clean.
- **Bytes.** 0 added lines carry `#` plus digits; 0 control bytes in the
changed files.

Declared narrowing: the 12-file base and head comparison ran outside
`os-verify-lock.sh`, after three queue turns (about 28 minutes) ended
without a grant. It is a 12-file run with two workers; the workspace
build, the typecheck and the full unit tier all ran under the lock. The
gates are `check:*` runs, which do not use the lock.

## Acceptance notes

- **Regrowth continues.** Since stage 27's landing, four PRs (objectstack-ai#22125,
objectstack-ai#22126, objectstack-ai#22259 and objectstack-ai#22322) added 7 messages / 8 ids to test strings in
files that already existed, one of them to a title objectstack-ai#22322 wrote while
stripping a ruling date from a describe. Test files sit outside
`check:doc-authoring`'s ledgered leg, and the ruling adds no gate, so
the per-stage census is the only instrument. An observation about the
burn-down's denominator, not a class a / b / c finding.
- **Comments are untouched.** Code comments in these files still cite
ids (for example `// ─── assignment (objectstack-ai#14149) ───` at
`builtin-node-config.test.ts:432`); comments are objectstack-ai#20234's share.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_

Co-authored-by: Claude <noreply@anthropic.com>
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

Development

Successfully merging this pull request may close these issues.

A digest — one message per recipient listing their N records — is not authorable in a flow

2 participants