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

feat(spec): assignment values may be a CEL value envelope — the expression ledger gains the value role#15113
zhuangjianguo merged 8 commits into
mainfrom
claude/issue-14149-assignment-value-role

Conversation

@claude

@claudeclaudeBot 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: d25a6606origin/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.
    • AssignmentValueSchemaz.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:41FieldRole = '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/linttypecheck → 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-63parseValue: 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.tsTest 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.mjsOK — 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-aliasOK 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

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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

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-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

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/mainf594e70d 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/main2cc46103: 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 d902edfd1 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 valeu5 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 FlowKeyValueFieldparseValue: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

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing provenancedomain: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/main2cc46103: 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 queueSep 4, 2026
Merged via the queue into main with commit f3bbbefSep 4, 2026
42 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14149-assignment-value-role branch September 4, 2026 02:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

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

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

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

feat(spec): assignment values may be a CEL value envelope — the expression ledger gains the value role#15113
zhuangjianguo merged 8 commits into
mainfrom
claude/issue-14149-assignment-value-role

Conversation

@claude

@claudeclaudeBot 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: d25a6606origin/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.
    • AssignmentValueSchemaz.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:41FieldRole = '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/linttypecheck → 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-63parseValue: 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.tsTest 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.mjsOK — 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-aliasOK 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

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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

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-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

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/mainf594e70d 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/main2cc46103: 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 d902edfd1 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 valeu5 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 FlowKeyValueFieldparseValue: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

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing provenancedomain: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/main2cc46103: 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 queueSep 4, 2026
Merged via the queue into main with commit f3bbbefSep 4, 2026
42 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14149-assignment-value-role branch September 4, 2026 02:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

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

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

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

feat(spec): assignment values may be a CEL value envelope — the expression ledger gains the value role#15113
zhuangjianguo merged 8 commits into
mainfrom
claude/issue-14149-assignment-value-role

Conversation

@claude

@claudeclaudeBot 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: d25a6606origin/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.
    • AssignmentValueSchemaz.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:41FieldRole = '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/linttypecheck → 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-63parseValue: 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.tsTest 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.mjsOK — 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-aliasOK 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

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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

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-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

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/mainf594e70d 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/main2cc46103: 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 d902edfd1 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 valeu5 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 FlowKeyValueFieldparseValue: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

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing provenancedomain: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/main2cc46103: 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 queueSep 4, 2026
Merged via the queue into main with commit f3bbbefSep 4, 2026
42 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14149-assignment-value-role branch September 4, 2026 02:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

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

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

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

feat(spec): assignment values may be a CEL value envelope — the expression ledger gains the value role#15113
zhuangjianguo merged 8 commits into
mainfrom
claude/issue-14149-assignment-value-role

Conversation

@claude

@claudeclaudeBot 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: d25a6606origin/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.
    • AssignmentValueSchemaz.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:41FieldRole = '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/linttypecheck → 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-63parseValue: 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.tsTest 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.mjsOK — 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-aliasOK 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

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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

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-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

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/mainf594e70d 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/main2cc46103: 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 d902edfd1 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 valeu5 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 FlowKeyValueFieldparseValue: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

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing provenancedomain: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/main2cc46103: 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 queueSep 4, 2026
Merged via the queue into main with commit f3bbbefSep 4, 2026
42 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14149-assignment-value-role branch September 4, 2026 02:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

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

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

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

feat(spec): assignment values may be a CEL value envelope — the expression ledger gains the value role#15113
zhuangjianguo merged 8 commits into
mainfrom
claude/issue-14149-assignment-value-role

Conversation

@claude

@claudeclaudeBot 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: d25a6606origin/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.
    • AssignmentValueSchemaz.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:41FieldRole = '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/linttypecheck → 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-63parseValue: 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.tsTest 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.mjsOK — 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-aliasOK 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

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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

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-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

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/mainf594e70d 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/main2cc46103: 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 d902edfd1 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 valeu5 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 FlowKeyValueFieldparseValue: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

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing provenancedomain: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/main2cc46103: 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 queueSep 4, 2026
Merged via the queue into main with commit f3bbbefSep 4, 2026
42 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14149-assignment-value-role branch September 4, 2026 02:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

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

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

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

feat(spec): assignment values may be a CEL value envelope — the expression ledger gains the value role#15113
zhuangjianguo merged 8 commits into
mainfrom
claude/issue-14149-assignment-value-role

Conversation

@claude

@claudeclaudeBot 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: d25a6606origin/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.
    • AssignmentValueSchemaz.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:41FieldRole = '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/linttypecheck → 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-63parseValue: 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.tsTest 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.mjsOK — 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-aliasOK 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

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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

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-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

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/mainf594e70d 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/main2cc46103: 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 d902edfd1 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 valeu5 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 FlowKeyValueFieldparseValue: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

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing provenancedomain: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/main2cc46103: 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 queueSep 4, 2026
Merged via the queue into main with commit f3bbbefSep 4, 2026
42 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14149-assignment-value-role branch September 4, 2026 02:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

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

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

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

feat(spec): assignment values may be a CEL value envelope — the expression ledger gains the value role#15113
zhuangjianguo merged 8 commits into
mainfrom
claude/issue-14149-assignment-value-role

Conversation

@claude

@claudeclaudeBot 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: d25a6606origin/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.
    • AssignmentValueSchemaz.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:41FieldRole = '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/linttypecheck → 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-63parseValue: 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.tsTest 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.mjsOK — 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-aliasOK 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

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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

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-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

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/mainf594e70d 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/main2cc46103: 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 d902edfd1 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 valeu5 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 FlowKeyValueFieldparseValue: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

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing provenancedomain: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/main2cc46103: 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 queueSep 4, 2026
Merged via the queue into main with commit f3bbbefSep 4, 2026
42 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14149-assignment-value-role branch September 4, 2026 02:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

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

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

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

feat(spec): assignment values may be a CEL value envelope — the expression ledger gains the value role#15113
zhuangjianguo merged 8 commits into
mainfrom
claude/issue-14149-assignment-value-role

Conversation

@claude

@claudeclaudeBot 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: d25a6606origin/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.
    • AssignmentValueSchemaz.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:41FieldRole = '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/linttypecheck → 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-63parseValue: 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.tsTest 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.mjsOK — 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-aliasOK 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

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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

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-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

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/mainf594e70d 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/main2cc46103: 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 d902edfd1 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 valeu5 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 FlowKeyValueFieldparseValue: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

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing provenancedomain: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/main2cc46103: 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 queueSep 4, 2026
Merged via the queue into main with commit f3bbbefSep 4, 2026
42 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14149-assignment-value-role branch September 4, 2026 02:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

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

@zhuangjianguo@claude