Skip to content

feat(spec,service-automation)!: a screen's defaults, a field's defaultValue and recordId are value slots — a CEL envelope evaluated before the screen is served, the {…} token refused (#19939 pass 4, S2) - #22832

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-19939-p4-s2-screen-trio
Oct 11, 2026

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Part of #19939
Clause-②: yes (narrowing: a {…} token is refused at screen.defaults.*, a field's defaultValue and screen.recordId; widening: screen.recordId accepts a CEL value envelope it refused, and its published JSON Schema and inferred type gain the envelope arm)

Pass 4, stage S2 of #19939 (stage plan 6096263424, claim 6107940231), under ruling D on #11182 and ADR-0032 Decisions 2 and 3. A screen node's three positions that hand a value to the client become value slots: an object form's defaults.*, a field's defaultValue, and an object form's recordId. Each takes a CEL value envelope, which the executor evaluates in the run's scope before the screen goes on the wire, or a literal. A single-brace {…} token is refused at all three. #19939 stays open for stages S3 to S7, so this PR uses Part of.

What lands

The contract and the ledger (@objectstack/spec).

  • ScreenConfigSchema.defaults takes FlowValueSlotSchema per key, and a field's defaultValue takes FlowValueSlotSchema. Each describe() is restated.
  • ScreenConfigSchema.recordId gains the envelope arm: a string, or a CEL value envelope (a union, so the published JSON Schema and the inferred type keep the string literal). A {token} in the string is refused by the one value-slot judge. Any other value (42, null, a list) is refused with a sentence naming what the id is for.
  • FLOW_NODE_EXPRESSION_PATHS appends three value rows: screen.defaults.*, screen.fields[].defaultValue, screen.recordId. LEDGER_DECLARED_NODE_CONFIG_SCHEMAS carries ScreenConfigSchema, as it carries MapConfigSchema since S1, because the descriptor offers these positions with no marker. The ratchet finds the three markers on the spec Zod channel and fields[].visibleWhen on the descriptor channel alone.
  • So the one judge (flowNodeValueTemplateRefusals) refuses a token at these positions at registerFlow and objectstack validate, and the envelope pass validates an envelope there. No second judge was added.
  • The screen sentence. At a top-level value, the refusal ends with what the client is served where the template served nothing. The table is keyed by the ledger entry, like S1's callee sentence.
  • dropped-refinements.baseline.json declares the three new dropped refinements (defaults.valueType, recordId, defaultValue). The header total moves 705 to 708.

The executor (@objectstack/service-automation).

  • screen-nodes.ts resolves the three readers through S1's value-slot resolver. defaults goes through resolveValueSlotMap. defaultValue and recordId go through the new single-value resolveValueSlot in value-slot-map.ts, which the map resolver now calls per key, so the dispatch by shape is spelled once.
  • An envelope goes to AutomationEngine.evaluateValueEnvelope in the run's scope and context. A literal is served as written. There is no ?? fallback to the interpolator.
  • recordId must evaluate to a non-blank string. Anything else fails the node there, naming its source; it is never String(value).
  • A screen that passes through without pausing goes on no wire and evaluates nothing, as before.
  • The descriptor's three descriptions, which said "Interpolates {var} references", are restated.

ADR-0087. The step-18 D3 entry 18.flow-value-slot-template-dialect-refused is amended:

  • the surface widens to the three screen positions;
  • replacement and reason say what each position serves where the template served nothing;
  • acceptanceCriteria gains the screen check, and also the callee caveat the S1 ACCEPT carried to this stage.

There is no D2: every whole-path spelling answers differently for an absent value. registry.ts is generated at build from the entries and gitignored at this base (.gitignore:68), so no regenerated registry file is in the diff. The typecheck's gen:migration-registry reports it current (422 semantic entries). The changeset marker is not-required (already-registered flow-value-slot-template-dialect-refused), and check-adr-0087-registration accepts it.

Sites migrated, re-derived at HEAD with the stage-0 census instrument. Positive control: the 3 tokened screen.defaults values and the 1 tokened screen.recordId value it found at base read 0 tokened at HEAD.

  • examples/app-crm crm_convert_lead_wizard: 3 defaults values. company is optional on a lead, so it is guarded.
  • content/docs/automation/flows.mdx: the object-form example, the value-slot list, and a new paragraph on what each screen position serves.
  • The text-slot-template.test.ts recordId fixture, and test fixtures in spec and lint.

Measured: what each position serves

Every reading is through the shipped AutomationEngine, on the screen the paused run returns (screen-value-slots.test.ts).

position envelope evaluates to a value absent, unguarded guarded has(…) ? … : null the template, before
defaults.* the value: a record as a map, a list as a list the run fails at the screen, naming the source null, served as a key with value null nothing: the key left the wire
fields[].defaultValue the value the run fails at the screen null nothing
recordId the string id the run fails at the screen the run fails: null is not a record id String(resolved): nothing, or [object Object] for a record

What the client does with it, read from objectui at the pinned .objectui-sha 20c6d351ad:

  • initialRecord.ts keeps a null prefill member as a value, and seedCreateValues puts the caller layer over the object schema's declared defaults. So defaults: { k: null } opens the field blank over the object field's defaultValue, which applied when the key was absent. The refusal and the D3 entry say: write that default in the guard.
  • ScreenView.initialScreenValues seeds a field from any defaultValue that is not undefined, null included, and resumeWith(values) submits it.
  • ObjectForm with mode: 'edit' and no recordId reads no record, and its save throws "Invalid form mode or missing record ID". This is why recordId refuses a null rather than serving such a form. The refusal tells the author to route around the screen with a decision.

Premises checked against f313fbcc99

  • Assumption 1 is false for recordId. At base an envelope there was refused (z.string()): the parse refused it with invalid_type, and the build doors with node-config-refused-by-contract@recordId. It was not served verbatim. It held for defaults and defaultValue. Measured by a probe through flowNodeConfigRefusals, flowNodeValueTemplateRefusals and ScreenConfigSchema.
  • The reader lines moved since the census: ref() was at :234 and used at :247, defaults was at :250, and defaultValue at :313.
  • Out of the claim's file surface, and why.
    • flow-node-config-refusals.ts. The build doors' value arm skipped every issue at or inside a value slot. That would have stopped refusing recordId: 42 at FlowSchema.parse, registerFlow and objectstack validate: a widening of the doors, because no other judge reads a value slot's literal type. It now skips a value slot only where the slot holds an envelope (the envelope pass owns it); a token is skipped by the existing carve-out. Every other value slot's literal arm refuses nothing but a token, so no other verdict moves.
    • In the same file, insideValueSlot matched only map paths. A malformed envelope at fields[0].defaultValue was also reported as node-config-key-required-by-rule at fields[0].defaultValue.source. It now matches [] segments too.
    • Both are pinned (flow-builtin-node-config-values.test.ts), and ablations 4 and 5 below turn them red.
    • schemaless-node-config.zod.ts: LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains screen (the S1 precedent for map).
    • value-slot-map.ts: resolveValueSlot. It is not exported from the package entry.

Pins, and the ablations that turn them red

  • screen-value-slots.test.ts holds 25 engine pins:
    • refusal at registerFlow, at the executor's guard and at the envelope pass, per position;
    • evaluation on the served spec, with no dialect on the wire;
    • absent and null per position;
    • recordId refusing null, a map, a list and a blank string.
  • In spec: contract pins (builtin-node-config.test.ts), ledger pins, screen-sentence pins (flow-value-slot-template.test.ts), the build doors' value arm, and the text-slot test re-judged in place. config-expression-ledger.test.ts covers the ratchet.
  • The lint hints flip by asking the judge, with no lint source change: 6 pins in lint-flow-patterns.test.ts.
  • Ablations, each through scripts/ablation-replace.mjs: the anchor hit once, the blob changed, and the restore was proven with blob equal to HEAD and git diff HEAD empty. Each was run at 528f5ca363.
    1. resolveValueSlot never evaluates (false && isExpressionEnvelopeShaped(value)): 11 of the 25 engine pins went red, the evaluation and absent/null pins. The refusal pins stayed green, as expected.
    2. recordId falls back to String(id): the 5 recordId refusal pins went red.
    3. The screen.recordId ledger row renamed: 10 spec pins in 3 files went red.
    4. The value-arm carve-out back to whole slots: screen {"recordId":42} is refused at recordId went red.
    5. insideValueSlot back to map-only matching: the "slot another judge owns" control went red. It showed the node-config-key-required-by-rule double report.
    6. The screen-sentence table emptied: 6 judge pins went red.
  • Not ablated at dist level: the service-automation and lint pins read @objectstack/spec's dist. Ablations 3 and 6 turned the same functions red at source.

Measurements

  • Gates at d693848a79. dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 115 commands from the 23 changed paths. All 115 exited 0. --ran with the recorded exit codes reads 115 run, 0 NOT-MEASURED (a derived zero).
    • This includes check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check:doc-authoring, check:i18n-coverage, check:examples-live-imports, check:nul-bytes, check:type-check-debt, and the spec gates check:generated, check:api-surface, check:export-origins, check:docs, check:spec-changes, check:upgrade-guide, check:authorable-surface and check:skill-examples.
    • On the first run, at 528f5ca363, three gates exited 3 (PREREQUISITE NOT MET): check:skill-examples, check:dual-build-cjs-loads and check:i18n-coverage. They passed once the packages they read were built, and passed again in the full second run.
    • The dispatch also named check:future-spec-major, which exited 0. There is no check:migration-registry script at this base.
  • Tests:
    • service-automation full suite at 528f5ca363: 186 files, 2397 tests pass;
    • spec local project, 648 files, 19370 pass; spec repo project, 55 files, 971 pass;
    • lint full suite, 137 files, 6422 pass;
    • example-crm, 6 files, 46 pass.
    • At d693848a79, after the second merge (which touched only core, mcp and metadata-protocol): the 5 touched service-automation files (109 tests), spec src/automation (1406) and the two touched lint files (227) pass.
    • Typecheck is green for spec, service-automation, lint and example-crm.
  • The CRM example through every judge. validateStackExpressions reports 0 issues, the flow lint's value-slot hints 0, and registerFlow accepts crm_convert_lead_wizard. Its screens serve {"name":"Acme"} for a lead with a company and {"name":null} for one without.
  • Semver. @objectstack/spec and @objectstack/service-automation are major (pre mode next, re-read at push). The Clause-② line reads yes (narrowing: …; widening: …), on this body and in the changeset (patch round 1, after the contract review 6109203893):
    • the narrowing: a {…} token is refused at screen.defaults.*, a field's defaultValue and screen.recordId;
    • the widening: screen.recordId accepts a CEL value envelope that z.string() refused, and its published JSON Schema (anyOf: [string, object]) and inferred type gain the arm: ScreenConfig['recordId'] infers string | AssignmentExpressionValue where it inferred string.
    • No api-surface/** or export-origins/** file moved. LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains screen, which widens LedgerDeclaredNodeType and ReconciledNodeConfigType; that is data in existing exports, as S1's map was.
  • Size: 23 files, +1185 / −145 against main (1330 changed lines).

Acceptance notes


Generated by Claude Code

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 11, 2026
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/runtime, @objectstack/service-automation, @objectstack/spec, touching 25 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/services/service-automation/README.md, packages/spec/dropped-refinements.baseline.json, packages/spec/src/automation/flow-text-slot-template.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json a6f3a13f2aea4275e2984709461e7f4b1201e17e.

⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/services/service-automation/README.md, packages/spec/dropped-refinements.baseline.json, packages/spec/src/automation/flow-text-slot-template.ts) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 144 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 a6f3a13f2aea4275e2984709461e7f4b1201e17e → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Read: card #19939 body and all 39 comments (stage plan 6096263424, census 6096228459 rows R4–R6 and stage_plan S2, S1 ACCEPT 6101189699, S1 landing 6101473453, triage 6107801130, S2 claim 6107940231, S2 report 6108947793); PR #22715 (merged f66fdc7973, 38 files) and its PASS record 6101076479; PR #22832 body, its 23 files and git diff origin/main...d693848a79 (merge-base c4e7fa5a31, +1185/−145, 23 files — equal to the PR file list); the head's check-runs (polled to completion); ruling D on #11182 (5805777944); ADR-0032 Decisions 2 and 3; ADR-0087 D2 and D3; .changeset/pre.json, .gitignore, the four 19939-*.md changesets on main; scripts/pm/clause2-line.mjs, check-adr-0087-registration.mjs (breakingDeclaration), check-changeset-no-major.mjs, the WHICH LEVEL prose in pr-automation.yml; PR #22747's line; objectui at the pin 20c6d351ad (initialRecord.ts, ScreenView.tsx, ObjectForm.tsx, previews/screen-spec.ts, flow-node-config.ts); objectui#12101.

Check-runs on d693848a79, polled every three minutes from 12:21Z until every run had a conclusion (12:49Z): 35 runs, 35 completed — 33 success, 2 skipped (Console Pin Gate: .objectui-sha does not move in this diff; Packed-tarball smoke (opt-in)), 0 failure. Green among them: Build Core, Test Core 1–6 and the roll-up, Dogfood Regression Gate 1–3 and the roll-up, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Type Check · source gates / · consumer gates / · workspace / · debt ledger and the roll-up, Check Changeset, Spec property liveness, Governed Surface Queue Guard, Build Docs, Check Documentation Links, Check PR Size, the four PR-claim guards. Every gate verdict is green; the verdict below rests on ② alone.

① Derived judgments

  • Accept set, the three positions. ScreenConfigSchema.defaults goes from z.record(z.unknown()) to z.record(FlowValueSlotSchema); a field's defaultValue from z.unknown() to FlowValueSlotSchema: a single-brace token is refused at FlowSchema.parse, registerFlow, objectstack validate and the executor's parse; an envelope and every literal accepted as before. A narrowing only at these two. RIGHT (ADR-0032 D2 computed value is whole-field CEL, D3 single brace deleted; ruling D's C half; census R4, R5).
  • recordId. z.string() becomes z.union([z.string(), AssignmentExpressionValueSchema]) with the token judge on the string arm. A token is refused (narrowing); an envelope, refused at base by z.string() (invalid_type) and by the doors (node-config-refused-by-contract@recordId), is ACCEPTED at head: a new accepted value on a published authorable key, and the published JSON Schema (anyOf: [string, object]) and ScreenConfig['recordId'] widen with it. The contract itself is RIGHT (census R6: "the contract gains the envelope arm"); its consequence for the declaration line is in ②.
  • The premise correction. Verified by reading: at the merge base recordId: z.string().optional() (builtin-node-config.zod.ts:899), so an envelope object failed the parse, and builtinValueJudged had no carve-out for it (not a value slot, not a region, not run-resolved, no token). Assumption 1 was false for recordId, true for the other two. It makes nothing in the changeset's "Why" or the D3 entry false (neither claims an envelope at recordId went on the wire). It does make Clause-②: no wrong (②), and the changeset's "Still accepted, unchanged. A CEL value envelope …" imprecise at recordId, where the envelope is newly accepted.
  • Ledger. FLOW_NODE_EXPRESSION_PATHS appends three value rows (screen defaults.*, screen fields[].defaultValue, screen recordId), every older row byte-identical and pinned; fields[].visibleWhen keeps its predicate row on the descriptor channel; the channel split is pinned (config-expression-ledger.test.ts). One judge (flowNodeValueTemplateRefusals) walks the ledger; no second judge. RIGHT.
  • LEDGER_DECLARED_NODE_CONFIG_SCHEMAS + screen. The S1 precedent for map: data in an existing export, read only inside spec; no api-surface/** or export-origins/** file moves (checked). Does not by itself flip the line. RIGHT.
  • The build doors' value arm (flow-node-config-refusals.ts, outside the claimed surface). Both versions read. atOrInsideJudgedLedgerSlot skipped every issue at or inside a value slot; it now skips one only where the authored value at the slot is envelope-shaped. For every value slot S1 and passes 1–3 declared, the contract is celValueSlotSchema (z.unknown() plus the token superRefine), so the only issue a literal can draw there is a token refusal, which the carriesInterpolationToken carve-out on the next line already skips; the map itself (fields: 'x') is shorter than the slot's segments in both versions. No verdict moves at assignments.*, fields.*, input.*, inputs.*. At recordId a literal the contract refuses for its type (42, null, a list) keeps being reported, as it was when recordId was not a slot. Without this edit the doors would have gone silent on recordId: 42 — a widening the dev caught. RIGHT; pinned (flow-builtin-node-config-values.test.ts), ablation 4.
  • insideValueSlot and []. Base split the path on . and matched * against any segment; head uses ledgerSlotSegments + startsWithLedgerSlot (* any string key, [] any index). For a map slot a record key is always a string, so the two match identically on every pre-existing slot; only fields[].defaultValue (new) changes, where a malformed envelope was double-reported as node-config-key-required-by-rule at fields[0].defaultValue.source. RIGHT; ablation 5.
  • Executor. recordIdOf: absent → undefined; otherwise resolveValueSlot, the S1 shape (isExpressionEnvelopeShaped ? engine.evaluateValueEnvelope(value, variables, where, context) : interpolate), a literal string returned as the contract's string; an envelope must evaluate to a non-blank string or the node throws, naming its source, never String(value). defaults through resolveValueSlotMap, each field's defaultValue through resolveValueSlot with fields[i].defaultValue as its label. No ?? to the interpolator anywhere (the only ?? is the pre-existing map ?? {}). The map resolver now calls the single-value one per key, so the dispatch is spelled once; the S1 callers (CRUD, subflow, map, script) are behaviour-identical. A pass-through screen evaluates nothing (pinned). RIGHT; ablations 1, 2.
  • Null and absent, per position, on the served screen. Pinned through the shipped engine (screen-value-slots.test.ts §3): unguarded absent fails the run at each position naming its source; guarded null served at defaults.* and defaultValue; recordId refuses null, a map, a list, a blank string; the template's [object Object] is gone. The client consequence is read at the pin: resolveInitialRecord keeps a null member; initialScreenValues seeds any non-undefined defaultValue; ObjectForm with mode: 'edit' and no recordId throws "Invalid form mode or missing record ID" on save and reads a record only when mode !== 'create'. RIGHT.
  • The edit/create consequence in the changeset. The edit consequence is stated ("its edit form opened with no record and could not save … route around the screen with a decision"). The rule is stated without a mode qualifier ("Anything else, null included, fails the run at the screen"), so it describes the uniform rule the dev shipped. Not false; "in either mode" would make it explicit. Non-blocking.
  • The screen sentence. SCREEN_VALUE_SLOTS keyed by the ledger entry, like S1's CALLEE_INPUT_SLOTS; absentCaseOf factored out of calleeSentence with the same truth table; reaches the two build doors and the lint hints through the one judge; the executor parse carries the base remedy alone (the S1 boundary). Six judge pins; ablation 6. RIGHT.
  • Lint. No packages/lint/src/*.ts source line moves; both hints flip by asking the judge (6 pins). No lint entry owed. RIGHT.
  • Descriptor. screen-nodes.ts keeps recordId: { type: 'string' } and defaults open; the engine reads a descriptor's configSchema for undeclared keys only ("structural only — no validation semantics", engine.ts:189), and screen is key-judged by the spec, so the descriptor's type refuses nothing. The Studio form consequence is objectui#12101's. RIGHT.
  • Sites. CRM crm_convert_lead_wizard 3 defaults values (census R4: 3), company guarded, name and account_id bare with the reason in the comment; flows.mdx example, the value-slot list, the new paragraph; the recordId fixtures. Match R4–R6 counts. RIGHT.
  • Out of surface, each mechanical: dropped-refinements.baseline.json (+3 sites, header 705→708 matches the body, 226 schemas unchanged); schemaless-node-config.zod.ts (S1's map precedent); value-slot-map.ts (resolveValueSlot, not exported from the package entry — checked); the descriptor's three descriptions and two comments this diff made false; builtin-node-config.mdx regenerated (Type Check · source gates green); tests. RIGHT.
  • Two sibling sentences this diff makes false, which the diff does not touch and the report does not name. (i) packages/services/service-automation/README.md:211: "Other value-like positions — a filter, recipients, an http payload, a screen's defaults — still read the single-brace dialect" — the README ships in the npm package (files: [dist, README.md, CHANGELOG.md]), and :191's value-slot row omits the screen positions. (ii) packages/spec/src/automation/flow-text-slot-template.ts:171: the FLOW_NODE_TEXT_SLOTS docblock lists "a screen's recordId / defaults / field defaultValue" among the positions keeping the single brace (S1 corrected the same docblock for its positions as a mechanical crossing). WRONG by omission; (i) is a published sentence. Both go in the patch round below.

② Semver level

  • Packages. @objectstack/spec and @objectstack/service-automation are the two published packages whose src/** moves; lint moves tests only; @objectstack/example-crm is private: true; docs are no package. No other published package moves. RIGHT.
  • Level. .changeset/pre.json on main is { mode: pre, tag: next }; check-changeset-no-major stands aside in pre mode; passes 1–3 and S1 graded the same two packages major; Check Changeset green on this head. major RIGHT. The ! in the title, the **BREAKING** banner, the FROM → TO table, the one-line fix, the ADR-0087 marker not-required (already-registered flow-value-slot-template-dialect-refused) (the id exists at base and head) — all present. RIGHT.
  • Clause-②: no (narrowing) — WRONG on the value. The repo's definition: the value answers 「本卡放宽接受集或扩大公开面吗」; WHICH LEVEL names "a new accepted key or value" on a published payload as a widening; clause2-line.mjs reads yes (narrowing) as "a diff that widens one surface and narrows another. Both facts are true and both are read", and no (narrowing) as "NOT a widening, but breaking". This diff narrows (a token refused at three positions) AND widens (recordId accepts a CEL envelope it refused at base; its JSON Schema and inferred type gain the arm). The dev measured only the export half (api-surface/**, export-origins/** unmoved) and flagged the type widening for the seat; S1's no (narrowing) was right because nothing there was newly accepted (z.unknown() already took an envelope), and the stage plan's no (narrowing) rested on the premise this report itself falsified. The line should read, on both carriers (PR body line 2, .changeset/19939-flow-screen-value-slots.md:14): Clause-②: yes (narrowing: a {…} token is refused at screen.defaults.*, a field's defaultValue and screen.recordId; widening: screen.recordId accepts a CEL value envelope it refused, and its published JSON Schema and inferred type gain the envelope arm) — PR feat(core,verify,cli): bootStack mounts the always-on slate and builds each provider from the app's configuration — item 1 gap of #22301 #22747's shape for a mixed change; the reader takes the arm narrowing from it. The level does not move (yes takes at least minor; major stands) and neither gate's verdict moves (breakingDeclaration reads the arm; the level axis is satisfied); what moves is a declaration that ships in the CHANGELOG. The changeset's "Still accepted, unchanged. A CEL value envelope …" takes the same edit at recordId.
  • Supersession. S1's changeset (19939-flow-callee-input-value-slots.md:32) lists "a screen's defaults" among the positions still reading the single brace; this changeset's "supersedes that line" sentence follows S1's own precedent for pass 3. Acceptable.

③ Boundary flags

Dev deviations (6108947793), each answered:

  1. flow-node-config-refusals.ts crossing — accepted; no S1 or pass 1–3 verdict moves, the recordId: 42 widening it prevents is real (①).
  2. The other crossings (schemaless, value-slot-map.ts, the baseline, the descriptor text, tests) — mechanical, accepted (①).
  3. flows.mdx two stale subflow lines fixed in place — same file, same defect class; accepted.
  4. No regenerated registry.ts — verified: .gitignore:68 is packages/spec/src/migrations/registry.ts, untracked at the merge base, the head and origin/main since build(spec): the migration registry is generated at build and leaves git #22706 (ed1de8c2db); the PR chore(objectui): bump the console pin to 0df67f237c5a (carries objectui#12089) #22812 re-sync note does not apply. RIGHT.
  5. Two merges of main — the merge-base diff equals the PR file list; process, no finding.
  6. Attribution form — process, no finding.

Open question (recordId refuses a non-string evaluated id in every mode) — A, uniform, is RIGHT. The slot's contract and its refusal are mode-independent; ObjectForm reads recordId only when mode !== 'create', so an evaluated recordId on a create form is dead either way and a mode branch would be a second rule for one slot kept in step with the client; the census and the grep measure zero authors writing recordId on a create form; loud everywhere is ADR-0032 D1's shape. The changeset's sentence carries no mode qualifier, so it already states the uniform rule.

Out-of-scope carriers, each judged:

  • objectui#12101 for the Studio form (flow-node-config.ts:857 help text {account_id}, recordId as a string input) and previews/screen-spec.ts — RIGHT carrier (the card says each later stage comments there; the plan says the seat extends it at ACCEPT). One precision: at the pin the preview interpolates title/description and CARRIES defaultValue and defaults as written, marking a {…} in defaultValue as "a template the run fills in" (defaultValueTemplates) — stale teaching, not interpolation. Nothing shipped here is false by it.
  • S7 for the validate-flow-template-paths doubling — RIGHT, the note passes 1–3 and S1 carried.
  • carrier none for the blank-source envelope lead at the executor parse of recordId — acceptable: the executor parse is the guard and the two build doors lead with VALUE_ENVELOPE_REFUSAL (the S1 boundary); the ScreenRecordIdSlotSchema docblock's "as in every value slot" over-reaches for that one case at that one door; internal, non-blocking.
  • carrier none for a literal recordId: '' — RIGHT, unchanged from base (z.string() took it).
  • seat post [PM seat] domain:spec · seat 2 — 🟢 marchtian · session_016njDy8ozy9B9Ns5Y8kAWEK #18549's ledger for hotcrm, NOT MEASURED — RIGHT, the plan's carrier.
  • none owed for skills/** — verified by grep at origin/main: no skill text names the three screen positions. RIGHT.
  • Missed (①): service-automation/README.md:211 and :191, and flow-text-slot-template.ts:171 — this PR's, not a later stage's: both are the "sentence this diff made false" class S1 fixed in place.

The patch round, one commit, re-reviewed on its head: (1) the Clause-② line on both carriers, to yes (narrowing: …; widening: …) as spelled in ②, and the changeset's "Still accepted, unchanged" sentence at recordId; (2) README.md:211 and :191; (3) flow-text-slot-template.ts:171. Everything else on this head is judged right and needs no change.

Implemented-by: claude/issue-19939-p4-s2-screen-trio
Reviewed-by: session_016njDy8ozy9B9Ns5Y8kAWEK

VERDICT: FAIL

…; widening) — recordId newly accepts an envelope — and three sentences the screen value slots made false (#19939 S2, patch round 1)

Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK
Co-authored-by: Claude <[email protected]>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Delta review of patch round 1, against the FAIL record 6109203893 at d693848a79 and the seat order 6109208049; the round-0 judgments on everything the delta does not touch stand as written there.

Read: the two commits since d693848a79 — 91688dfdde (merge of origin/main a6f3a13f2a, parents d693848a79 + a6f3a13f2a) and f915b87714 (text only, +19/−14 in 5 files) — git diff 91688dfdde f915b87714 in full; git diff d693848a79 91688dfdde restricted to the 26 PR files (empty); main's moved paths from c4e7fa5a31 to a6f3a13f2a, and from a6f3a13f2a to the current origin/main 31b5a5f7f5 (three later commits: cli, plugin-webhooks, platform-objects), intersected with the 26 PR files (empty both times); the PR body after the issue_patch (line 2, line 93, the footer); the dev's report 6109593912; the posted record 6109203893; scripts/pm/clause2-line.mjs (CLAUSE2_KEY_LINE, matchValueToken, readArmToken, clause2LineDescribes, hasInlineClause2Key, readClause2Line); zod 4.6.x handleUnionResults and util.aborted and the $ZodCheck continue: !def.abort flag, in the installed package; expression.zod.ts EvaluatedExpressionSchema; .changeset/22110-flow-text-slot-double-brace.md:24 and 19939-flow-callee-input-value-slots.md:32 on main; #22586 (body and four comments); the head's check-runs.

Check-runs on f915b87714: 42 runs, 42 completed — 38 success, 4 skipped, 0 failure, 0 pending. The four skips: Console Pin Gate (the pin does not move) and Packed-tarball smoke (opt-in), plus Auto Label and Check PR Size on the second pr-automation run 38143745635, the one the body edit triggered, where both are by-design skips and whose Check Changeset is success beside the first run's. Green among the required set: Build Core, Test Core 1–6 and the roll-up, Dogfood Regression Gate 1–3 and the roll-up, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, the four Type Check · jobs and their roll-up, Check Changeset (both runs), Spec property liveness, Governed Surface Queue Guard, Build Docs, Check Documentation Links, the four PR-claim guards. mergeable_state is clean.

① Derived judgments

  • The delta is text. f915b87714 touches a changeset, a README, two JSDoc blocks and one comment: no Zod rule, no executor line, no test, no ledger row, no generated artifact moves. The merge commit 91688dfdde changes none of the 26 PR files, and nothing main moved since the merge base — through a6f3a13f2a or through the three commits after it — intersects them. Every ① judgment of round 0 holds unchanged on this head. RIGHT.
  • The two extra edits, each true at f915b87714. (a) packages/runtime/src/domains/automation.ts:3198: "interpolated" → "resolved" — the comment describes what a ScreenSpec carries on that route, and at this head screen-nodes.ts resolves defaults / defaultValue through resolveValueSlotMap / resolveValueSlot; the {record.email} on the next line is the measured history the comment recounts. True; comment-only, so it owes no @objectstack/runtime entry (Check Changeset green, and the level axis grades only packages whose published source moves). (b) The ScreenRecordIdSlotSchema docblock now says the union's own parse leads with VALUE_ENVELOPE_REFUSAL "except for an envelope the envelope arm parses and then refuses (a blank source): the union reports that arm's own sentence." Confirmed by reading zod's union: handleUnionResults returns an option's own result when exactly one option is non-aborted; util.aborted reads an issue without continue: true as aborting; a .refine() issue carries continue: !def.abort, so true. For { dialect: 'cel', source: '' } the string arm aborts (invalid_type) and the envelope arm's only issue is the NON_BLANK_STRING refine on source (EvaluatedExpressionSchema), so that arm is returned as is and the union's error map, which fires only on invalid_union, never runs: the sentence is EVALUATED_EXPRESSION_SOURCE_REQUIRED at recordId.source, without the lead. For { dialect: 'cel' } (invalid_type on source) and { dialect: 'template', … } (invalid_value on the literal) both arms abort, invalid_union fires and the lead applies — the two shapes the spec pin covers. The narrowed docblock is exactly what holds. RIGHT.
  • The registry. Still generated and gitignored; the dev's typecheck reports it current (422 semantic entries); nothing to regenerate in the diff. Unchanged from round 0.

② Semver level

  • Packages and level. @objectstack/spec and @objectstack/service-automation major in pre mode next, unchanged; packages/runtime joins the file list with a comment only and owes no entry. Check Changeset green on both runs; check-adr-0087-registration's annotation on both runs reads the marker not-required (already-registered …). RIGHT.
  • Clause-② — CLOSED on both carriers. PR body line 2 and .changeset/19939-flow-screen-value-slots.md:8 both read Clause-②: yes (narrowing: a {…} token is refused at screen.defaults.*, a field's defaultValue and screen.recordId; widening: screen.recordId accepts a CEL value envelope it refused, and its published JSON Schema and inferred type gain the envelope arm) — byte-identical (256 bytes each, compared). Applying the reader's own patterns to both: CLAUSE2_KEY_LINE matches at line start with no backtick on the key (so no QUOTED-AND-CONTINUED tell) and the key appears once (no TWICE-NAMED tell); matchValueToken reads yes (followed by a space, not an alternation); readArmToken finds (narrowing followed by :, outside the token class, so the arm is narrowing; no (widening) contradiction not present. Both carriers: declared, value yes, arm narrowing. The PR body's only other mention, line 93 (- **Semver.** … The \Clause-②` line reads …), is neither key-initial nor an inline near miss (the text after the backticked key is " line reads", not a colon), so it neither competes with nor shadows line 2 — readClause2Linereturns line 2. The dev recorded the same reading from the reader itself;Check Changeset's level axis, which refuses yeswith every moved package atpatch, is green with both at major`. RIGHT.
  • The changeset text around the line — CLOSED. The BREAKING banner names the widening beside the narrowing; the first paragraph says recordId "gains the envelope arm, which it refused before this change"; a new "Newly accepted" paragraph states the z.string() refusal, the JSON Schema arm and ScreenConfig['recordId']; "Still accepted, unchanged" is narrowed to defaults, defaultValue and every literal. Nothing in the changeset now calls recordId's envelope unchanged. RIGHT.

③ Boundary flags

The FAIL record's items, each closed at f915b87714:

  1. Clause-② value, PR body — closed (②).
  2. Clause-② value, changeset line — closed, byte-identical to 1 (②).
  3. The changeset's "Still accepted, unchanged" at recordId — closed (②).
  4. packages/services/service-automation/README.md:211 — closed: the single-brace list now reads "a filter, recipients, an http payload, a map or loop collection", the screen gone; and :191's value-slots row now names "a screen's defaults, field defaultValue and recordId, each evaluated before the screen is served" with recordId's literal as the id. Both true at this head (the collection positions are S3's and still read the single brace).
  5. packages/spec/src/automation/flow-text-slot-template.ts:171 — closed: the three screen positions move from the single-brace list to the value slots in the FLOW_NODE_TEXT_SLOTS docblock; the single-brace list that remains (recipients, actionUrl, sourceId, payload, templateData, http, collection, filter) is true at this head.

Dev deviations (6109593912), each answered:

  1. Push before the gate run — the gates then ran on the pushed head and nothing was pushed after; process, no finding.
  2. The merge, and the three later main commits left unmerged — verified: none touches a PR file; no finding.
  3. The two extra sentences — both true (①); the runtime comment adds a package to the file list with no publishable change; accepted.
  4. The two pending changesets left to release(changesets): four merged pending changesets state behaviour that later PRs on the same v18 train changed; correct them in place through check-empty-changeset's DELIBERATE CORRECTION path before the release #22586 — judged below.

The two pending changesets: leaving them to #22586 is RIGHT, and is the only route the repo allows this PR. .changeset/22110-flow-text-slot-double-brace.md:24 ("Unchanged: every other flow string keeps the single-brace dialect — … a screen's recordId / defaults / field defaultValue, subflow.input, script.inputs, map.input, …") is a merge-base changeset; it has been false for the three callee maps since S1 landed and is false for the three screen positions since this PR lands. S1's 19939-flow-callee-input-value-slots.md:32 ("a screen's defaults") is the line this changeset's own "supersedes that line" sentence names. check-empty-changeset refuses a diff that modifies a merge-base changeset until a person confirms the DELIBERATE CORRECTION, which is the maintainer's word, not a seat's — so an edit here would red a required context by design, and #22586 exists precisely to make that one correction PR (it already carries items 1–4, the pass-3 pointer 6095708172 and the #22627 pointer 6098372629). Not a false sentence this PR ships: both lines sit outside its writable set, and this changeset's first paragraph, BREAKING banner, "Newly accepted" and "supersedes that line" sentences read correctly in CHANGELOG order. Owed by the seat at landing, not by this PR: a pointer comment on #22586 naming 22110-flow-text-slot-double-brace.md:24 (its callee-map half is S1's debt, unposted at S1's landing, and its screen half is S2's) and 19939-flow-callee-input-value-slots.md:32, in the shape of 6095708172. New finding, non-blocking.

Carriers from round 0, unchanged and still right: objectui#12101 (with the dev's restated precision on the preview: carries, marks, does not interpolate), S7 for the validate-flow-template-paths doubling, none for the blank-source lead (now stated truthfully in the docblock) and for a literal recordId: '', seat post #18549's ledger for hotcrm, none owed for skills/**.

Implemented-by: claude/issue-19939-p4-s2-screen-trio
Reviewed-by: session_016njDy8ozy9B9Ns5Y8kAWEK

VERDICT: PASS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants