feat(spec)!: every engine-evaluated expression slot requires a non-blank source - #18638
Conversation
…ed slots Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…y envelope Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…ing arm Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…ns and reference docs Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 546adee9fd0285407ad26a2a2a30f86c01a75f5e && git checkout 546adee9fd0285407ad26a2a2a30f86c01a75f5e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d4cb05cbf0a8dda962974533ee634393a66440fa 34a63b9d583c881d4c45d7187206417b0c82c17b && git checkout -B drift-repro d4cb05cbf0a8dda962974533ee634393a66440fa && git merge --no-ff 34a63b9d583c881d4c45d7187206417b0c82c17b
node scripts/docs-audit/affected-docs.mjs --json d4cb05cbf0a8dda962974533ee634393a66440fa
|
A narrowing PR that grows the published export surface widens on a second axis. `shared/evaluated-slot-union.ts` follows the `union-branch-policy` convention: reachable inside `@objectstack/spec`, absent from both barrels, so `api-surface/` and `export-origins/` do not move for it. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
Seat verdict: REWORK — the review PASSed and two of its named corrections ship false textSeat What the review settled, and it settled it well⭐ The central question — whether It also gives #18640 its root cause: the instrument's #17618 three-fact spend requires the added member list to be a textual subset of the removed one, and here the member was renamed. ⛔ The gate's prescribed clear is a matcher repair with a self-test case, ⛔ never a false What must change — inside this PR's own surface
⛔ Not owed — do not re-open
Carried, not foldedThe review named two things that are ⛔ not this PR's: Generated by Claude Code |
…s messages
The changeset and the ADR-0087 migration entry both said the union-member
positions leave their sibling arm untouched. Measured on this branch's base
`00115a8442` and at head: true for `RecordAlertProps.visible` (boolean) and
`ServiceLevelIndicator.successCriteria` (structured object), FALSE for
`TraceSamplingConfig.composite[].condition`, whose record arm gained a
`.refine()` and refuses six shapes the base accepted through that arm alone.
A changeset becomes the CHANGELOG and a migration entry becomes the migration
ledger, so both now state the narrowing, its FROM -> TO and its control.
The refine becomes aborting. That is about the MESSAGE and never the accept
set (measured identical either way): non-aborting, it was the surviving arm
for every expression-shaped refusal here, so a blank `source` collided with it
and the slot published a bare `Invalid input` while the sentence sat nested,
and `{ dialect: 'js', source: 'x' }` was refused with a sentence about
`source` that misnames its fault. Aborting hands each refusal back to its
owner: one `custom` issue at `source` for a blank `source`, one
`invalid_union` carrying the published sentence for an `ast`-only envelope or
a blank bare string.
Pins: the tracing slot's accept set, both blank spellings' published sentence,
and the negative (a non-`source` fault is not blamed on `source`); and the
population pin now covers all three refused spellings at all 36 positions
instead of only the `ast`-only one.
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…nce page `gen:migration-registry` picks up the corrected surface / acceptance text, and `gen:docs` republishes `TraceSamplingConfig.composite[].condition` with the `dialect` rule its schema enforces. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…aluated-slot-narrowing
…ribe `check:doc-authoring` flags an internal issue id in customer-facing spec text (maintainer ruling 2026-08-12). The rule the describe publishes is unchanged; only the trailing reference is gone, and the reference page is regenerated. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS
Generated by Claude Code |
契约复核记录 — 交付后复核(delta 轮),档位
|
⛔ 落地阻断 —— 契约复核 PASS,但本 PR 在 CI 上真红 16 条,且红是本 PR 自己造成的
两件事互不矛盾,先说清楚契约复核的 PASS 成立且在其职权内:它测的是契约语义(接受集、semver 档、公开面),它在自己的隔离检出里只构建了 入队资格是本席的职权:「入队资格 = 每个 check 为 success 或预期 skip,⛔ 不是 required 子集」。本席读了,不合格。 实测(head
|
| 结论 | 条数 |
|---|---|
| 在 main 上也红 | 1 —— TypeScript Type Check |
| 在 main 上是 success,只在本 PR 红 | 16 |
⭐ 仪器是亮的:对照表两个分支都取到了值 —— TypeScript Type Check 命中「main 上也红」,证明该分支可达;⛔ 不是一个永远只会答「你的锅」的坏仪器。
在 main 上绿、只在本 PR 红的 16 条:
Build Core · Test Core + 6 个分片 · Dogfood Regression Gate + 3 个分片 · Dogfood Verify CLI · Type Check · workspace · Type Check · consumer gates · Type Check · debt ledger
根因 —— 一条,不是十六条
十六条全部塌在同一个包的 DTS 构建上(Failed: @objectstack/platform-objects#build),错误逐字:
src/pages/sys-user.page.ts(88,9): error TS2322: Type '{ dialect: "cel" | "cron" | "template";
source?: string | undefined; ast?: unknown; meta?: {...} | undefined; }'
is not assignable to type 'string | { dialect: ...; source: string; ... } | undefined'.
Types of property 'source' are incompatible.
Type 'string | undefined' is not assignable to type 'string'.
⇒ 这正是本 PR 的收窄本身:EvaluatedExpression* 要求 source: string,而 packages/platform-objects/src/pages/sys-user.page.ts:88 仍然递进一个 source 可选的信封。一个第一方消费者没有跟着改。
@objectstack/platform-objects#build 一挂,Build Core、三条 Type Check、六个 Test Core 分片、四条 Dogfood 全部连带塌掉。修好这一处,十六条应当一起回绿 —— 这是预测,⛔ 不是读数,由补丁轮实测。
这恰恰是收窄该有的样子,⛔ 不是复核失职
一次契约收窄本来就该把不合规的调用点照出来。复核测的是「收窄是否正确且已声明」——它是;CI 测的是「谁在用旧形状」——sys-user.page.ts:88 在用。两个读数都对,合起来才是完整判断。本 PR 声明了破坏性变更却没有修自己仓内的消费者。
处置
- 契约复核 PASS 照记(
5715671761),⛔ 不撤、⛔ 不改。 - 本 PR 进补丁轮,原因是 CI 红,⛔ 不是契约 FAIL。
needs:contract-review继续挂着,⛔ 不在此刻摘除。理由说明白以便审计:规则写「PASS ⇒ 剥标、ready、auto-merge」,那是一个以「PR 可落地」为前提的动作单元;本 PR 不可落地,且补丁轮若动到契约面就欠一次 delta 复核。轮次未完,载体不摘。--pair 18638仍读 exit 4(C5)。复核已认定两条 tell 为假、修矫正器归 [finding]check-widening-tells.mjsreports T1 and T2 on lines added only because a zod options object was appended to an existing union — the clause-② enqueue gate refuses a diff that adds no key and no arm #18640、本 PR ⛔ 不得翻Clause-②: yes。⚠️ 本席补记一条给接 [finding]check-widening-tells.mjsreports T1 and T2 on lines added only because a zod options object was appended to an existing union — the clause-② enqueue gate refuses a diff that adds no key and no arm #18640 的人:该卡目前是未定级的finding(只有finding一个标签),按派发规则未定级的 finding 不可派发,归分诊席。⇒ 在 [finding]check-widening-tells.mjsreports T1 and T2 on lines added only because a zod options object was appended to an existing union — the clause-② enqueue gate refuses a diff that adds no key and no arm #18640 被定级并落地之前,本 PR 的--pair读数不会自己变 0;这是本 PR 之后还会撞上的第二道门,现在记下来,免得补丁轮回绿之后再被它挡一次。
Generated by Claude Code
…aluated-slot-narrowing Conflict resolved by hand in packages/spec/src/system/metrics.zod.ts: both intents stack — main's new DurationSeconds import plus this branch's swap of ExpressionInputSchema for EvaluatedExpressionInputSchema and the union refusal helper. ExpressionInputSchema has no remaining use in the merged file. The two both-sides-edited os-regen artifacts take main's side in this commit; the regeneration follows as its own commit. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
Discharges the os-regen deferral the merge commit recorded. Both pages carry main's incoming content plus this branch's narrowing: the evaluated-slot envelope now prints `source: string` instead of `source?: string`, and metrics.mdx keeps main's `window.durationSeconds` rename. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…aluated-slot-narrowing
…ed on the origin/main merge The merge of origin/main (b146102) routed four paths to the os-regen driver, which defers rather than text-merging: the three api-surface-declarations shards automation.txt, data.txt and ui.txt, and the generated reference page content/docs/references/ui/component.mdx. On every one of them the driver kept this branch's side and silently dropped main's, so step 2 of the sanctioned sequence restored main's side into the worktree and this commit re-derives all four from the merged tree. What each side contributed, now both present: ui.txt / component.mdx main's element:text.variant widening to the published nine (#19019), plus this branch's evaluated-slot narrowing data.txt main's transport no-transactions declaration (#18890) and the $orderby dual declaration (#19018), plus this branch's `source: string` narrowing on ConditionalValidationSchema and CrossFieldValidationSchema automation.txt main's structured-region pause/end refusal (#18688), plus this branch's narrowing The two MIXED, deliberately unrouted paths were hand-resolved by git's ordinary three-way merge and verified rather than eyeballed: registry.ts's hand-written remainder (generated regions stripped with the merge script's own awk) is byte-identical across base, both sides and the merge, and its line count is exactly additive (17142 + 121 + 74 = 17337), with both sides' migration entries present by id. component.zod.ts is additive too (3750 + 4 + 45 = 3799) and its single .superRefine() is untouched. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
Clause-② 复核记录 —— 合并增量复核,PASS
为什么范围是 8 而不是整条二点差分
五问读数(每问都带已点亮的控制项)
复核方自报并当场改正的一处仪器错误首轮「51 条同 blob」扫描曾误标 5 条为 MAIN CHANGED: 与本 PR 正文的关系正文〈Base catch-up〉小节里的数字与本复核独立测得的一致(17142+121+74=17337、3750+4+45=3799、计数器 202/553/367/3)。 ⛔ 复核方未推送、未改 PR、未动工作树。 Generated by Claude Code |
…/main Discharges the os-regen deferral from the prior merge commit. root.txt and shared.txt now reflect main's #18638 evaluated-expression-slot narrowing (source becomes required across the composing slots) plus the drift picked up while catching this branch up to main's current tip. No hand edits.
Main shipped the identical /`expression` return-type narrowing to EvaluatedExpression first, under #18638 (card #15811) -- confirmed by the merge: both sides made the same change independently, and ADR-0137's own status line says its PR carries no schema change. Re-announcing that narrowing here would duplicate #18638's own changeset entry in the same release. Drop the redundant paragraphs and keep only what #17778 alone ships: the ADR-0137 predicate fault-semantics contract and its ADR-0089 addendum.
Fixes #15811
Clause-②: yes
Rework round — the seat's three items, measured on the rework head (⚠️ NOT the current head — see 〈Base catch-up〉 below)
Seat verdict #18638 (comment) (REWORK on a PASSed contract review). Three items, nothing else re-opened.
1. The changeset and the ADR-0087 entry said something this diff makes false
Both claimed the three union-member positions leave their sibling arm untouched. Re-measured here, base
00115a8442vs head, parsing each value AS MOUNTED throughTraceSamplingConfigSchema:RecordAlertProps.visiblez.boolean()true/falseacceptedServiceLevelIndicator.successCriteria{ threshold, operator, percentile? }dialectkeyTraceSamplingConfig.composite[].conditionz.record(z.string(), z.unknown())At the tracing slot, six shapes the base accepted through that arm alone — measured: the base's
ExpressionInputSchemarefuses all six, so the record arm was the only thing admitting them — are refused at head:condition{ dialect: 'cel' }{ dialect: 'js', source: 'x' }{ dialect: 'nope', source: 'x' }{ dialect: 'cel', source: 5 }{ dialect: 'cel', source: 'x', meta: { rationale: 5 } }{ dialect: 'zzz', foo: 1 }Control that HITS: a structured filter carrying no
dialectkey —{},{ service: 'api' },{ attributes: { 'http.route': '/v1/orders' } }— is accepted at base and at head alike. Without it the sixrefuseds would be a schema that refuses everything.⭐ The narrowing is correct and load-bearing (it is what makes the ruled change non-inert at that slot) and is not removed. What changed is the description: the changeset now carries the table and its FROM → TO, and the migration entry's
surfaceandacceptanceCriteriaboth name the wider sweep that slot needs — flag everyconditionobject carrying adialectkey, not only the two spellings. A changeset becomes the CHANGELOG and an ADR-0087 entry becomes the migration ledger; neither may ship a false sentence.2. The published reference page
.refine()has no JSON Schema projection — measured against zod 4.4.3:z.toJSONSchemareturns byte-identical output for the plain record, the refined record and the aborting refined record ({"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}). So regenerating alone could never move that TYPE cell, and hand-editing the page is forbidden and would be reverted. The fix is at source: the slot's.describe()now states the rule, andgen:docsrepublishes it.content/docs/references/system/tracing.mdxnow reads:.refine()in the spec, not only this one. Making the page contradict the artifact beside it would be worse. Reported as an out-of-scope finding rather than repaired here.3. The two unpinned message cells — the fix was in the schema, not only in a test
Measured at the slot, on head as it arrived:
{ dialect: 'cel', source: '' }→ one top-levelinvalid_unionwith the bareInvalid input; the published sentence appeared only inside nested arm issues;{ dialect: 'js', source: 'x' }→ refused with the 「needs a non-blanksource」 sentence, which misnames the fault: that value'ssourceis fine, its dialect is not.Root cause, measured: zod 4.4 reports the ONE arm that did not abort, else
invalid_union. The record arm's.refine()was non-aborting, so it was the surviving arm for every expression-shaped refusal here and answered for all of them — and it answered with the other arm's sentence.⇒ The repair is in the schema, not only in a test: the refine becomes aborting, and its message becomes the arm's own rule (module-local, ⛔ not a new published export). Ablation of the accept set: the refused set is identical with and without
abort— both measured over the ten-value corpus above, so this is a message change and not a second narrowing. After it, the slot answers exactly what the other 35 answer, and exactly what the migration entry's own acceptance criteria promise:condition{ dialect: 'cel', source: '' }/' 'invalid_union@ slot,Invalid inputcustomissue @…condition.source, the published sentence{ dialect: 'cel', ast: … }custom@ slot, published sentenceinvalid_union@ slot, published sentence''/' '(bare)invalid_union@ slot, published sentence{ dialect: 'js', source: 'x' }custom@ slot, publishedsourcesentenceinvalid_union@ slot,Invalid input— no longer blamessourcePins, in
packages/spec/src/system/tracing.test.ts: the accept set (six refusals + the accepting control), both blank spellings' published sentence and its exactcode/path, theast-only and bare-string cells, the negative (a non-sourcefault is not answered with thesourcesentence), and the.describe()the reference page renders. Andevaluated-slot-population.test.ts's published-sentence pin now runs all three refused spellings at all 36 positions instead of only theast-only one — 108 cases, all green. That is what would have caught this slot in the first place.Not re-opened
Clause-②is nowyes, re-declared by the seat under ruling A (batch #155 item 3,5725503887, maintainer 「同意」 2026-09-18T05:13Z). The 28-input strict-subtype measurement is NOT overturned — the conclusion drawn from it is: 「a member replaced on a key line is a change to a published contract … the same review a narrowing owes regardless of the tell」. ⇒ a false tell was never the question; a narrowing owes the at-tier review on its own.minor+ BREAKING banner + ADR-0087 disposition stay.printCelAst, the package-internal helper and the 36-position census stay.packages/spec/api-surface/shared.jsonandexport-origins/shared.jsonare still hash-identical to base (git hash-object:cf260910f1…/0429ff67a6…), andgit diff --stat 00115a8442..HEAD -- packages/spec/api-surface packages/spec/export-originsis empty.Gates, re-derived on the rework head (⚠️ two figures superseded — see the note under it)
node scripts/pm/dispatch-gates.mjs --commandson the merged head, every exit code recorded as it ran, reconciled with--ran: 110 derived, 104 run, 6 NOT MEASURED, 0 unrun (--ranexit 0).pnpm --filter @objectstack/spec build && test && typecheckgreen — 486 files / 14016 tests;@objectstack/formula30 files / 871 tests, typecheck green.check:generated: all 15 artifacts up to date after theorigin/mainmerge and the final rebuild.110 derived, 104 run, 6 NOT MEASURED, 0 unrunreconciliation was taken one892c86e271. The head is now34a63b9d583. ⛔ It is not re-derived here — read it as the reading it was.all 15 artifactsis superseded. feat(spec): pin every export by its .d.ts declaration text, and retire the 27 signature hashes #18971 registered a new generated-artifact family and the registered count is now 16. Measured: theGATEDarray inpackages/spec/scripts/check-generated.tscarries 16 entries at70407326463d, ate892c86e271and at34a63b9d583alike — with a dark control on a non-existent array name extracting 0 lines — so the15predates all three heads rather than describing any of them. The15is left above verbatim as the historical reading.34a63b9d583:check:generatedexit 0 — all 16 generated artifacts up to date, working tree clean.Base catch-up —
origin/mainmerged, four deferred artifacts regeneratedMerged
origin/mainb14610255101atab00016c221, regenerated in34a63b9d583. Theos-regendriver deferred on four routed both-sides paths —api-surface-declarations/automation.txt,data.txt,ui.txtandcontent/docs/references/ui/component.mdx— and ⭐ a deferral silently keeps ONE side: in the merge commit, main's token reads 0 on all four while the branch's side is intact. Main's side was restored and all four re-derived from the merged tree; on the head each path carries both counts, against a positive control that hits on every blob of every path (⛔ a control that fires on one shard certifies one shard).The two ⛔ MIXED paths are deliberately not routed to the driver and were hand-resolved.
packages/spec/src/migrations/registry.ts: generated regions stripped, the hand-written remainder byte-identical across base, both sides and the merge, line counts exactly additive (17142 + 121 + 74 = 17337), and both sides' migration entries present by id.packages/spec/src/ui/component.zod.ts: additive text merge (3750 + 4 + 45 = 3799), its.superRefine()untouched.Refinement census over non-test
packages/spec/src, counted by occurrence rather than by matching line:.refine(59 base / 60 branch / 59 main / 60 head;.superRefine(80 at all four;.check(4 at all four. ⛔ Nothing deleted, nothing weakened.Non-zero exits, all declared:
check:doc-formula-expressions,check:doc-security-posture,check:docs-transcript-drift,check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt) — each refuses an unbuilt workspace closure and says so. NOT MEASURED, not findings;check:skill-examplesexit 1 — the same class in exit-1 clothing (packages/client-react/distholds no.d.ts);check:react-declaration-parityexit 1 — run as CI runs it,MANIFEST="$PWD/sdui.manifest.json" … --strict:111 spec-only divergences, 1 blocks missing from the registry. Control: the identical command at base00115a8442prints the same two numbers, so it is pre-existing and this PR moves neither.One gate went red on this round's own work and is fixed:
check:doc-authoringrefused an internal issue id in customer-facing spec text — the(#15811)this round put in the tracing.describe(). Removed, page regenerated, gate green; the same gate at base is green, so the id was the only offender.node scripts/pm/check-clause2-carriers.mjs --pair 18638— exit 4, and the dual-carrier row C1 is gone: only C5 remains, with the same two false tells (ui/action.zod.ts:833T2,ui/component.zod.ts:1595T1). ⛔ Reported, not acted on; the matcher repair is #18640's.Rework round authored by the
domain:specexecution seat, sessionsession_01LvwGppdonww4zGLWZo5rho.Decision batch #122 item 2 generalised the evaluated-slot rule:
EvaluatedExpressionInputSchemanow composes into every slot an engine evaluates, whileExpressionSchema/ExpressionInputSchemastay the persistence contract (sourceORast) by item 2 of the same ruling. Anast-only envelope and asourcethat is blank after trimming — through the envelope key or the bare-string shorthand — are refused at the door instead of parsing, registering, and faulting at run time.The population was re-derived, not inherited
The census in the card is six days old and
shared/expression.zod.tsmoved after the ruling, so the 36 figure was treated as a premise. Re-derived by identity on this branch's base00115a8442— a negative lookaround on identifier characters, because the bare substring also fires insideCronExpressionInputSchema,TemplateExpressionInputSchemaandEvaluatedExpressionInputSchema, which is the trap that inflated triage's own reading on this card (32 files, five of them Cron-only):ZzzNoSuchSchemaThat 7-versus-17 gap in one file is the trap itself, in miniature. Identical to the measured census (#15811 (comment)), position for position. Two aliases:
ui/action.zod.tsActionConditionInputSchema(mountsvisible+disabled) andsystem/settings-manifest.zod.tsSettingsVisibilityInputSchema(mounts the specifier and manifestvisible). Three positions reach the schema as a union member rather than head-of-declaration.PredicateInputSchemais a plain alias ofExpressionInputSchemawith zero slot users; it stays wide with the schema it aliases.Two defects found while measuring, both fixed here
1. The narrowing was INERT at
TraceSamplingConfig.composite[].condition. That slot isz.union([z.record(z.string(), z.unknown()), …]), and a bare record arm accepts{ dialect: 'cel', ast }as an ordinary record — so swapping the other arm changed nothing. Measured: after the swap and before this fix the slot still answeredsuccess: trueon theast-only envelope, while its 35 siblings answeredfalse. The structured-filter arm now declines an object carrying adialectkey, which is an expression attempt whatever it got wrong. Shipping the swap alone would have been a declared-but-unenforced narrowing.2. Four positions refused with zod's bare
Invalid input. Where the declaration wraps the evaluated schema in a WIDER union — a boolean beside it onaction.visible/action.disabled/RecordAlertProps.visible, a structured object beside it onServiceLevelIndicator.successCriteria— the outer union reportsinvalid_unionat the slot and the inner union's sentence never surfaces.evaluatedExpressionUnionRefusalgives those unions the published sentence. It is deliberately stricter than the inner map it complements: it answers only for a blank string or an object carryingdialect, so a malformed threshold object is not blamed onsource. It lives inshared/evaluated-slot-union.ts, package-internal and absent from both barrels, on theunion-branch-policyconvention: a narrowing PR that grows the published export surface widens on a second axis, soapi-surface/andexport-origins/do not move for it.Item 3 — the printer path is real, and measured
The ruling asked for the lossless direction 「where the dialect has a printer」 before falling back to a structured TODO. Measured rather than assumed:
@marcbachmann/cel-jsshipsserialize, andcel-engine.tsalready uses it for its own scope rewrites. So@objectstack/formulagainsprintCelAst(ast), the inverse of the existingparseCelToAst, and the migration entry prescribes it by name instead of describing a capability nobody can call.Measured round-trip, six sources, each re-evaluated on the same scope:
Lossless about MEANING, not bytes — the printer re-renders from the parse tree, so quote style normalises. Dark controls, all four throwing rather than inventing a source:
{},null,{ type: 'nope' }and a plain string each raiseUnknown AST operation.printCelAstconverts that intonulland additionally requires the printed text to parse back through the platform's own boundedparseCelToAst, so it can never widen what this platform evaluates.Where the printer answers
null, and for every blanksource, the ADR-0087 D3 entryevaluated-expression-slots-source-requiredis the structured TODO — naming the object, the field and the slot, and splitting the judgment by fail policy, because removing a key is safe on the fail-soft half of the population and a silent disclosure on the fail-closed half.Why this is a D3 entry and not a D2 conversion, now that a printer exists. The conversion layer lives in
packages/spec, which is dependency-free by Prime Directive #2 and carries no engine —packages/formula/src/normalize.tsstates the same boundary from the other side. A conversion that had to call the CEL printer could not live where conversions live, and one that guessed without a printer would be the platform inventing a predicate.minor, and the ruling saidmajorItem 3 ordered a 「
majorchangeset」.scripts/check-changeset-no-major.mjsforbids amajormarker during the launch window, because the fixed group versions in lockstep and onemajorpromotes all ~70 packages to a whole-stack major — which is a release act reserved to the maintainer. The guard's own header names the two carriers the convention uses instead, and both are present: the BREAKING banner in the changeset body and the ADR-0087 disposition line. The ruling's substance ships; only the marker differs, and it differs because a repo gate forbids the marker. Flagged rather than chosen silently.Item 4 — the mechanical acceptance surface
#17630 is closed and its widening is live on this base: discovery in
packages/qa/dogfood/test/expression-conformance.test.tsmatches a roster name by identity anywhere on a line, attributes it to thefield:it mounts, and resolves file-local aliases. BothExpressionInputSchemaandEvaluatedExpressionInputSchemaare on that roster, so every one of the 36 positions stays discovered across the swap, the ledger'sfile:Schema.fieldcover keys are unchanged, and theSCAN_CONTROLSfloors (head 37 / inline 3 / alias 2) are unaffected — the swap changes the identifier, never the syntactic shape. No ledger row'sfailPolicymoves: the column records what the EVALUATOR does with a bad expression, and no evaluator changed.Clause-② carrier readings, reported rather than acted on
5725503887), and the seat has since acted. C5 below reads three widening tells against aClause-②: nothat no longer stands: the declaration is nowyes, so the C5 tell no longer gates this PR and the at-tier review does. C1 (the split dual carrier) is also closed — the seat hungneeds:contract-reviewon BOTH card #15811 and this PR at 2026-09-18T09:52:56Z / 09:52:58Z. ⛔ The matcher was NOT touched and no C-class licence card was opened; ruling A refuses both by name. The readings below are kept unedited as the record of what was measured at the time.node scripts/pm/check-clause2-carriers.mjs --pair 18638— exit 4, two rows at the time of writing (re-read on the rework head: C1 has cleared, C5 stands — see the rework section above). ⛔ Neither carrier is touched from here; this is the reading, not a verdict.C1 — the dual carrier is split.
needs:contract-reviewis on card spec: the evaluated-slot rule of #15430 reaches only the flow-node ledger — every otherExpressionInputSchemaslot an engine evaluates (formulaexpression, validation / hook / sharingcondition,visibleWhen…) still accepts anast-only or blank-sourceenvelope #15811 and NOT on this PR. That is the state as found; the seat that owns the gate hangs or clears both sides in one stroke.C5 — three widening tells against
Clause-②: no. One was real and is gone: the new published exportevaluatedExpressionUnionRefusalinapi-surface/shared.json, removed by moving the helper package-internal (above), so the published surface is byte-unchanged by this PR. The remaining two are false, and both for the same reason — the matcher fires on an ADDED LINE that has the shape of a widening, and these two lines were added because an options object was appended to a union that gained no member:ui/action.zod.tsActionConditionInputSchema— read as T2 「a new member of a closed set」. The union has the same two members before and after; what is new on the line is, { error: … }.ui/component.zod.tsRecordAlertProps.visible— read as T1 「a new key on a Zod object schema」.visibleexisted before this PR; the line moved for the same options object.Per the gate's own instruction a false tell is repaired in the matcher (
scripts/pm/check-widening-tells.mjs, with a--self-testcase pinning the shape) or filed as its own card. Repairing ascripts/pm/**matcher is outside this card's surface, so it is filed rather than done here — see the report'sout_of_scope_findings.Tests
packages/spec/src/shared/evaluated-slot-population.test.tsis the new pin, in two halves because either alone is a green that proves nothing:packages/spec/srcstill mounts the persistence schema on a code line, with a lit control (the scan does find the name in the definition file and the barrel), a dark control, and an explicit assertion that the Cron / Template / Evaluated siblings do not leak in as substrings;ExpressionSchema/ExpressionInputSchema/PredicateInputSchemastill ACCEPT both shapes, and a healthy predicate still parses at all 36 (the settings pair gets the predicate its own closed grammar accepts).packages/formula/src/print-cel-ast.test.tspins the printer's two claims, including seven dark-control inputs.Three existing pins were rewritten rather than relaxed — each pinned exactly the arm this PR deletes:
system/settings-manifest.test.ts「anast-only envelope is opaque at this layer」 now pins the refusal, and asserts the settings GRAMMAR message is not the one raised, so the two refusals stay independent;ui/action.test.ts「rejects composition with an AST-only visible loudly (ADR-0078)」 — ADR-0078's promise is unchanged, but the refusal moved from therequiresFeaturelowering to the slot, so it now holds with and without the flag. A second case pins that spec/kernel:requiresFeaturecomposed with a blank-sourcevisiblebuilds a predicate that can never parse — ADR-0078's no-silently-inert guard produces the shape it exists to reject #17631's shape (a blanksourcecomposed into( ) && features.admin == true) can no longer reach the lowering at all;ui/view-form-features-root.test.ts「documented boundary」 now asserts the refusal comes from the evaluated-slot rule and not from the features-root scanner this file is about.Repo census for the migration: zero authored occurrences of either refused spelling outside
packages/spec's own refusal fixtures, acrosspackages/,examples/,content/andskills/, against a lit control that hits. Nothing in this repository needs rewriting.Acceptance notes
PredicateInputSchema(shared/expression.zod.ts) remains a plain value alias ofExpressionInputSchemawith zero slot users. Left wide deliberately — it aliases the persistence contract. Noted, not filed; carrier is the ledger's own limit 2, already written up there.celEngine.evaluateon{ dialect: 'cel', source: '' }answers with the AST-only message rather than an empty-source one. Message accuracy only; the verdict is correct. Unchanged here, still uncarried.Authored by the
domain:specexecution seat, sessionsession_01LvwGppdonww4zGLWZo5rho, under the dispatch claim #15811 (comment).Generated by Claude Code