Repository navigation
feat(spec,service-automation,lint)!: a loop's and a map's collection is a value slot — a CEL envelope or an inline array, a string refused (#19939 pass 4, S3) - #22883
Conversation
…ots (#19939 S3) Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
…19939 S3) Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
…and references for FlowCollectionSlotSchema (#19939 S3) Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
…the merged tree The os-regen driver kept the branch side of the index with exit 0; step 2 took main's side and the hook refused it as stale. Regenerated with gen:schema then gen:docs from the committed merge: the index now counts both sides (API +2 from importUnmanagedPackage, automation +1 from FlowCollectionSlot), 1523 schemas. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): ⛔ 3 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 139 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 9899cc1aa567e370ead60d1d8cbbda2ff9cc4b8c && git checkout 9899cc1aa567e370ead60d1d8cbbda2ff9cc4b8c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3fdb92db00b3380bb166e749f377960c42db0831 817bde9da3402971acdb908eca09f6a7960565a2 && git checkout -B drift-repro 3fdb92db00b3380bb166e749f377960c42db0831 && git merge --no-ff 817bde9da3402971acdb908eca09f6a7960565a2
node scripts/docs-audit/affected-docs.mjs --json 3fdb92db00b3380bb166e749f377960c42db0831
|
Contract reviewServed-tier: Read, and nothing else: card #19939's stage plan ① Derived judgments
② Semver level
③ Boundary flags
Nothing on this head needs a change before the queue. The landing is the owning seat's, through the queue, with this record on the head and every check green. Implemented-by: VERDICT: PASS Generated by Claude Code |
Landing pre-checks at
|
…in slotsForType (objectui#12170) objectstack-ai/objectstack#22883 (9fdb12752c) retired the `flow-template` role from FLOW_NODE_EXPRESSION_PATHS and moved its two rows (loop.collection, map.collection) to `value`, so `entry.role === 'flow-template'` is TS2367 against spec main and the Spec Main Shape Gate fails every objectui PR. The branch was redundant at the installed spec: both nodes' descriptors declare `collection` as `kind: 'expression'`, `refMode: 'template'`, which already files the slot under `slots.template`. The module comment's dialect list drops the ledger slot. A pin covers the map's collection beside the loop's. Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude <noreply@anthropic.com>
…tree The merge of origin/main (7dbb89d) brought FlowCollectionSlot (#22883); content/docs/references/index.mdx is generated and not text-merged, so it is rendered again from the merged spec: Automation 71 schemas, total 1524. Kernel keeps the branch's 158 (InstallMode). Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
Part of #19939
Clause-②: yes (narrowing: a string is refused at loop.collection and map.collection, a {…} token and a variable's bare name alike; widening: both accept a CEL value envelope they refused, their published JSON Schema and inferred types gain the envelope arm, and FlowCollectionSlotSchema is a new export)
Pass 4, stage S3 of #19939 (stage plan
6096263424, claim6110982937), under ruling D on #11182 and ADR-0032 Decisions 2 and 3. The list aloop(with abody) and amapiterate, theircollection, becomes a value slot: a CEL value envelope the executor evaluates in the run's scope, or an inline array, iterated as written. A string there is refused: a single-brace{…}token, naming its CEL spelling, and a variable's bare name, the arm the contract declared and the structured executors never read, naming that variable in an envelope. #19939 stays open for stages S4 to S7, so this PR usesPart of.What lands
The contract (
@objectstack/spec).FlowCollectionSlotSchemais the one contract both keys take: a list, or a CEL value envelope (AssignmentExpressionValueSchema). It is a union, likescreen.recordId's, so the published JSON Schema and the inferred types keep the list arm and losestring.{…}token gets the value-slot judge's own refusal, word for word. A bare name gets the rule, then the envelope naming it (rowsbecomes{ dialect: 'cel', source: 'rows' }). A malformed envelope leads withVALUE_ENVELOPE_REFUSAL, as in every value slot. Before this change an envelope here was refused with zod's default "Invalid input".LoopConfigSchema.collectionandMapConfigSchema.collectiontake it. Eachdescribe()is restated.LoopConfigSchemadrops itsxExpression: 'template'meta (see the ledger).The ledger.
FLOW_NODE_EXPRESSION_PATHS'loop.collectionandmap.collectionrows move from theflow-templaterole tovalue, in place.loop.collectioncarries a new optional field,onlyWithKey: 'body'. Both walks (resolveFlowNodeExpressions,resolveFlowNodeValueSlots) skip the row on a body-lessloop: that is the legacy flat-graph form, which readscollectionas a bare variable name into$loopItems/$loopIndex, and no door judges it. It is the ledger's statement of the contract map'sparsedWhen, and a test holds the two equal.flow-templaterole recorded these two slots and no other, so it leavesFlowNodeExpressionRole. The ratchet'sROLE_BY_MARKERdropstemplate, so a descriptor that marks a slotxExpression: 'template'again fails as an unknown marker. This is the same defect class, a mechanical removal in a file this claim holds, and the same gate family.xExpression: 'value'on theloopandmapdescriptors'collection; it was'template'). The contract carries none, soMapConfigSchema, which the spec channel projects, does not declaremap.collectiona second time. One slot, one channel, as S1 kept it.The judge.
flowNodeValueTemplateRefusalsjudges a collection with a new option,valueSlotTemplateRefusals(value, { collection: true }). Only the value itself is judged. An inline array's strings are data: the executors never interpolated one, so a{…}inside one is not refused.[], which iterates nothing:has(vars.rows) ? vars.rows : []. The value slots' usual guard answersnull, and anullcollection fails the node, so that guard is never offered here. Any other token (text with holes, an expression, a date macro, the run user) never computed a list, and the refusal says so instead of spelling a concatenation or a date.flowNodeConfigRefusals) refuses a token-free string atcollectionin the contract's words, as it refusesrecordId: 42. A token is left to the judge, a malformed envelope to the envelope pass, as before.The executors (
@objectstack/service-automation).resolveCollectionSlot(builtin/value-slot-map.ts) is the one reader. An envelope goes toAutomationEngine.evaluateValueEnvelopein the run's scope and context, like every value slot. An inline array is the list as written: it is never handed to the interpolator, whose recursion would resolve a{…}the executors never resolved. No??fallback was added.loop(with abody) andmapcall it, and fail the node on anything but a list with a returned failure naming what the envelope evaluated to and its source. The token and bare-name arms, and the dead fallback to a variable lookup, are deleted. A body-lessloopis unchanged.loopandmapdescriptors restatecollection:xExpression: 'value', notype(an envelope object or a list; a string is refused), like a screen field'sdefaultValue.AutomationEngine.evaluateValueEnvelope's docblock lists every caller (the S1 carry). The registration failure header no longer says template slots such asloop.collectionrequire braces. ThevalidateFlowExpressionscomments no longer describe the retired role.The lint (
@objectstack/lint, patch).validate-flow-template-pathsinfers a loop iterator's object from an envelopecollectionnaming a variable, as it did from the token. Without this, every migrated loop would have lost its{iterator.field}path checks without a word.validate-expressions.ts: one comment restated. The value-slot pass reaches the collection through the ledger with no lint source change, soobjectstack validaterefuses a token and a bare name and reads an envelope's roots like any value slot's.ADR-0087. The step-18 D3 entry
18.flow-value-slot-template-dialect-refusedis amended: its surface widens to the two collections, and its replacement, reason and acceptance criteria say what the list must evaluate to, what an absent one does, and why the bare name is refused rather than rewritten. There is no D2 (below).registry.tsis generated at build and gitignored, so it is not in the diff. The changeset marker isnot-required (already-registered flow-value-slot-template-dialect-refused), andcheck-adr-0087-registrationaccepts it.Premises measured at the base
55382dc02aMeasured through the shipped
AutomationEnginewith a throwaway probe (installBuiltinNodes,registerFlow,execute), deleted after the run.LoopConfigSchema.collectionwasz.union([z.string().min(1), z.array(z.unknown())])withxExpression: 'template', "a{token}template or bare variable name … or an inline array";MapConfigSchema.collectionwasz.union([z.string(), z.array(z.unknown())]).FlowSchema.parse(andregisterFlow, which parses first) reached them throughflowNodeConfigRefusals, whose contract map parseslooponly with abody(parsedWhen). The ledger rows were roleflow-template, which every validator skipped.'{rows}',rows = [1, 2]'rows',rows = [1, 2]NODE_FAILURE"collection 'rows' did not resolve to an array"['a', 'b']['{a}'],a = 'resolved''{a}'(never interpolated){ dialect: 'cel', source: 'rows' }registerFlow: "Invalid input"'{rows}',rowsabsent ornullNODE_FAILURE"did not resolve to an array"'rows'$loopItems = [1, 2],$loopIndex = 0'{rows}'or an envelope{name}to{ dialect: 'cel', source: 'name' }: a present list gives the same list, and a present non-list ornullfails the node either way with a returned failure. An absent variable does not: the template failed the node with a returned failure (NODE_FAILURE), andevaluateValueEnvelopethrows "Unknown variable: rows", a stepEXECUTION_ERROR. Afaultedge routes both, but the step and the run record them differently. Not lossless.nameto the same envelope: on a structured loop or map the bare name never iterated and the envelope iterates, so the rewrite changes what the flow does. On a body-less loop the bare name works and the envelope is ignored. Not lossless in either direction.The reach
Re-derived at HEAD; the positive control is the base's 9 token sites in shipped surfaces, which read 0 at HEAD (the two remaining string mentions are prose describing the refused bare name).
examples/app-showcase(theshowcase_batch_remindersandshowcase_nested_fan_out_remindersloops, theshowcase_release_signoffmap),examples/app-todo(thetask_reminderandoverdue_escalationloops),content/docs/automation/flows.mdx(three samples, the value-slot list, and a new paragraph on the collection), andpackages/services/service-automation/README.md. Each was a whole{name}token on a structured node. No shipped flow wrote a bare name.packages/platform-objectsandpackages/qa/dogfoodwrite nocollection.loop/mapfixtures invalidate-expressions.test.ts, then to inline arrays, because the lint now refuses an envelope naming a variable the flow never binds. Body-less loop fixtures keep their strings.1d7148bf2d, deleted after): eleven structured loops in nine flow files, and one test fixture, write a whole{name}token (one is a template literal); none writes a bare name, and none is amap. It is on@objectstack/spec17.7.0, so nothing there moves until it upgrades.4997995022: the designer's offline field groups rendercollectionas a single-brace picker (flow-node-config.ts:684forloop,:700formap, placeholders{leadList}/{items},refMode: 'template'), andjson-schema-to-fields.ts:255mapsxExpression: 'template'to that picker.FlowNodeInspector.loopItemVariable-9340.test.tsxparsesLoopConfigSchema/MapConfigSchemawithcollection: '{leadList}'/'{items}'and asserts success, so it reds when objectui installs this spec. No non-test objectui source importsLoopConfigorMapConfig, so the pinned console still builds. Carried by the seat to the objectui companion card.Pins, and the ablations that turn them red
collection-value-slots.test.ts(service-automation, 21 engine pins, both positions): a token and a bare name refused atregisterFlowand by the executor's contract parse; a body-less loop judged nowhere, with$loopItems/$loopIndexunchanged; an envelope iterates, a computed one too (rows.filter(r, r > 1)); an inline array iterates as written; an absent variable fails withEXECUTION_ERRORnaming it; a guard answering[]iterates nothing;nulland a string fail the node withNODE_FAILUREnaming what the envelope evaluated to.flow-value-slot-template.test.ts: the remedy reads clean, nonullguard is offered, a dotted path is guarded a step at a time, other tokens never computed a list, an inline array is data, the contract and the judge use the same words), ledger pins (flow-node-expression-paths.test.ts, including theonlyWithKey/parsedWhenagreement), build-door pins (flow-builtin-node-config-values.test.ts).validate-expressions.test.ts(refused token and bare name, a clean envelope and array, an unbound root, a body-less loop) andvalidate-flow-template-paths.test.ts(the envelope spelling resolves the iterator).map.collectionpins, the descriptor marker pins (loop-node.test.ts,map-node.test.ts,config-schemas.test.ts,control-flow.test.ts), the ratchet's required-slot cross-check, and the lint test that called the bare name "the spelling loop-node.ts falls back to".8f0b85818a, each throughscripts/ablation-replace.mjsin WRAP mode: the anchor hit once, the blob changed, and the restore was proven with the blob equal to HEAD andgit diff HEADempty.loopexecutor (a stringcollectionread as a variable instead of refused):loop: the executor's contract parse refuses both as a guardwent red, 1 of 21.onlyWithKey: 'body'deleted: 5 pins went red in two files. The first attempt was refused by the tool as a no-op (its replacement was a prefix of its anchor, so the replacement count could not rise) and restored; the re-run used--delete.@objectstack/spec'sdist. Ablations 2 and 3 turned the same functions red at source.Measurements, at HEAD
3e6eedc7863e6eedc786mergesorigin/main0253192f76throughscripts/pm/os-regen-merge.sh; the merge left nothing to regenerate, andcheck:generated --fixthen rebuiltpackages/specand read all 14 generated artifacts up to date. Every lock reading below is a shared-box figure.scripts/pm/os-verify-lock.sh, each part's exit captured on its own):@objectstack/spectest(thelocalproject): 648 files, 19394 passed, 1 todo, exit 0.@objectstack/service-automation: 187 files, 2421 passed, exit 0, run at0510e4ad0a. Noservice-automationfile changed after it. The merge brought threespec/srcfiles (description text inautomation/approval.zod.ts,contracts/approval-service.ts,ui/component.zod.ts), and the suite was not re-run over them; CI runs it.@objectstack/lint: 137 files, 6471 passed, exit 0, after the merge.@objectstack/metadata-protocol, the three edited files: 113 passed, exit 0.turbo run build --filter='@objectstack/cli^...' --filter=@objectstack/cli, 59 tasks) then--project integration test/migrate-meta-engine-guidance.test.ts: 3 passed, exit 0. The amended D3 entry prints with no tracker number.@objectstack/spec(withcheck:test-typecheck),@objectstack/service-automation,@objectstack/lint,@objectstack/metadata-protocol,@objectstack/example-showcase,@objectstack/example-todo.eslint --no-inline-config --format jsonover the 77 changed.tsfiles (the populationeslint.config.mjs's**/*.{ts,…}blocks select); the JSON reports 77 files, 0 errors, 0 warnings. The config never enables type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 117 commands from the 87 changed paths (1834 changed lines, under 3000). 116 ran: 114 exited 0 at once;check:skill-examplesandcheck:i18n-coverageexited 3 (prerequisite not met:@objectstack/client-reactand the example closures unbuilt), and after those builds both exited 0.--ranreads 117 derived, 116 run, 0 NOT-MEASURED, 1 UNRUN:check:dual-build-cjs-loads, NOT MEASURED, reason: it needs a whole-workspace build, which the dispatch excludes.check-adr-0087-registration --base origin/main(not-required (already-registered)),check-changeset-no-major(pre modenext),check-empty-changeset,check:doc-authoring,check:examples-live-imports,check:nul-bytes,check:type-check-debt,check:durability-log-level,check:cross-package-test-inputs,check:test-source-alias, and the spec gatescheck:generated,check:api-surface,check:export-origins,check:docs,check:spec-changes,check:upgrade-guide,check:authorable-surface,check:liveness.check-closing-target-claim,check-partof-closing-keyword,check-single-claim-paths) are not wired without a pull request;check-partof-closing-keywordon this body read clean before the create, and the three are re-run wired to this PR after it opens.Out of the claim's file surface, and why
packages/lint/src/validate-flow-template-paths.ts(+ test): the collection reader in the lint, see above. A lintpatchis in the changeset.packages/lint/src/validate-expressions.ts: one comment this diff made false.packages/spec/src/automation/flow-node-config-refusals.ts,flow-text-slot-template.ts,schemaless-node-config.zod.ts: comments this diff made false.packages/services/service-automation/src/engine.ts: comments, the registration failure header, and theevaluateValueEnvelopedocblock (S1's carry to this stage).metadata-protocol,lint), each a collection spelling.check:generated --fixproved stale:api-surface/,export-origins/,declaration-map/,json-schema.manifest/(the new export) and the references.Acceptance notes
STEP18_RATIONALE's fragment for this entry (registry.ts.template) still names only theassignmentvalues and the CRUDfields; S1 and S2 left it so as well. It is incomplete, not false. Carrier: S7 (the canon fixes).engine.ts's value-role comment invalidateFlowExpressionsstill says "a plain string there is{token}interpolation", false since the first pass. Carrier: S7.skills/objectstack-automation/SKILL.md:65) namesconfig.collectionand no dialect. It is not false; the seat's skills(objectstack-automation): SKILL.md still teaches{$User.<path>}as a kept value-slot spelling and the text-slot run-user remedy through it, which #19939 pass 2 (PR #22563) refuses #22585 pointer can add the envelope.Generated by Claude Code