Repository navigation
feat(spec)!: FlowSchema refuses an edge whose endpoint names no node of its graph, and a repeated edge - #22119
Conversation
…f its graph, and a repeated edge An edge's source and target must name nodes of the graph that declares it (the top-level graph, or the region body it sits in), and a second edge the engine cannot tell apart from an earlier one (same source, target, type, condition and branch label) is refused, anchored on the later copy. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…r the D3 entry and changeset Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…ave door, draft and publish Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…arse ceiling only Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also 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 4e0967cc7f2dada67928950d9f6d4186b429ee74 && git checkout 4e0967cc7f2dada67928950d9f6d4186b429ee74
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a543e244f038c81e7b36d93345ace72041f7c5e0 a4ababd1da76a7fddc82bc2a0d0ff4b6372716b9 && git checkout -B drift-repro a543e244f038c81e7b36d93345ace72041f7c5e0 && git merge --no-ff a4ababd1da76a7fddc82bc2a0d0ff4b6372716b9
node scripts/docs-audit/affected-docs.mjs --json a543e244f038c81e7b36d93345ace72041f7c5e0
|
Contract reviewServed-tier: Inputs, and nothing else: card #22088 (body and all five comments; triage grade Check-runs on the head, read 2026-10-07T18:51Z — 36 runs: ① Derived judgments1. Refusal A — an endpoint that names no node of the edge's own graph: RIGHT. Judged per graph over 2. Refusal B — a repeated edge: RIGHT, with one dimension wider than the engine's (point 3). Key 3. The key against
4. Every door, read in source:
5. ADR-0087 disposition for stored flows — RULE A, as shipped: registered D3
6. Ledger mechanics: entry file 7. Public surface: no export added or removed ( ② Semver level
③ Boundary flagsReport Deviations
Open questions
Out-of-scope findings (carrier 承接者:无)
Reviewer's advisories, none blocking
Landing: this record is the PASS the seat's hold names; the other half of that hold — every gate-carrying check-run Implemented-by: VERDICT: PASS |
Fixes #22088
Clause-②: no (narrowing)
What changes
FlowSchema'ssuperRefinegains two refusals. Both run in the region walk the node-id rule already uses (collectFlowGraphs), so every door that parses a flow agrees:os validate/os compile(viadefineStack),defineFlow,AutomationEngine.registerFlow(which parses first), and the metadata save door in draft and in publish mode.edges.N.source/edges.N.target. For a region edge the anchor is the region path, such asnodes.N.config.body.edges.M.target. The message names the missing id and the graph. When the id is a node of a different graph, the message names that graph too.edges.N(the later copy), naming the earlier copy by position and id.The issue is the same
customZod issue the duplicate node-id and edge-id rules raise. That gives422 INVALID_METADATAwith locatedissues[]at the save door. No new error code.Measured decisions (the dispatch's mechanism hypotheses)
H1, confirmed on
aa71c4d9d. ThesuperRefinerefused duplicate node ids (one id space across every region) and duplicate edge ids. It checked neither edge endpoints nor repeated pairs.H2, regions. The engine resolves an endpoint in the graph that declares the edge.
traverseNextlooks the target up in the flow it walks, andrunRegionruns a region against a view of its own nodes and edges. So the node set an edge resolves against is its own graph's (graph.nodes). The id space for uniqueness stays one across the flow. A cross-region edge was legal nowhere in effect before this change:registerFlowbyanalyzeRegion;Both are now refused at parse.
H3, what "repeated" means. It is the key the engine selects on in
traverseNext, and nothing wider:sourceandtarget;type: afaultedge is followed only on failure;condition, compared by dialect and source, the two partsevaluateConditionreads. So a bare CEL string and its envelope are one condition;label: adecisionorapprovalnarrows its out-edges to the label it selected, soapproveandrejectmay both reach one node.isDefaultis deliberately not in the key. An unconditional edge and itsisDefaultcopy are both taken whenever no conditioned sibling holds, so that pair is refused.labelis one field more than triage's wording ("same edge type and condition"). Without it, an approval routingapproveandrejectinto one node would be refused. The engine treats those as distinct branches. I flagged this for the contract review.H4, write doors.
saveMetaItemruns the registered type schema before the publish gate, in draft and in publish mode. That is the ADR-0027 house rule for drafts: staging runs per-item Zod. So the draft save that Studio's save-then-publish loop starts with is refused now, and nothing is stored for publish to promote. These pins cover the publish-mode and the draft-mode save on the real protocol path (protocol.invalid-metadata-422-face-inventory.test.ts, section 9), with a CONTROL that stores the well-formed flow.Read in source, not pinned:
promoteDraftForPublishdoes not re-parse. A draft stored before this change is still promoted, and is then refused atregisterFlow: the flow is not armed, and a warn names it. That is the load-path question triage left to the contract review.H5, corpus first: no hit, so no migration question was triggered. Census at
aa71c4d9d, throughcollectFlowGraphs, with a planted positive control that fired on every arm:app-showcase,app-crm,app-todo: 35 flows, 55 graphs counting region bodies, 131 edges) has 0 dangling endpoints, 0 repeats, and 0 edge pairs sharing asourceandtargetat all;os generateandos explaintemplates, the new-flow seed, the@objectstack/verifyfixture, and the platform checklist's flow bodies;flowinpackages/cli/src/lint/corpus.ts).ADR-0087 disposition (for the contract review to settle)
I propose a registered D3 semantic entry,
flow-edge-unresolved-or-repeated-refused(protocol 18), with its step-18 rationale fragment. This follows the precedent of the other flow parse narrowings in step 18: builtin config values, the approval contract, and decision branch expressions. The changeset's ADR-0087 marker isregistered flow-edge-unresolved-or-repeated-refused.There is no D2 conversion. A dangling endpoint carries no intent a rewrite could recover. Dropping a repeated copy changes how many times its target runs, which is the author's call. The changeset is
@objectstack/specminorwith the BREAKING banner, because.changeset/pre.jsonis absent onorigin/main.Verification (all at
a4ababd1d)pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/automation: 31 files, 1035 tests passed.spec full,
--project local: 622 files, 18579 passed, 1 todo.consumer fixtures, every test file that mentions
edges(after building the closure,turbo build, 26 tasks successful):metadata-protocolobjectqlrestlintservice-automation(full suite)pnpm --filter @objectstack/spec typecheckexit 0;pnpm --filter @objectstack/metadata-protocol typecheckexit 0.Fixture triage. One fixture was re-judged, none batch-rewritten. The node-id test "raises one issue per later occurrence" shared
start → n → endedges that name no node of its own node list. I gave it an edge of its own, because it was never well-formed under this rule. No consumer fixture went red.Ablation, committed state, spec tests resolve
./flow.zodfromsrc(no dist on the path). Each mutation went throughscripts/ablation-replace.mjs, which checks that the anchor hits 1 time and goes to 0 after the edit:6f05aaecagain, equal toHEAD, andgit diff HEADis empty.The door pins read spec through
dist; they were not ablated (NOT MEASURED, rebuild cost).Gates:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ranreports "90 derived famil(ies) accounted for — 90 run, 0 NOT-MEASURED (a DERIVED zero — all 90 recorded an exit code and none of them is 3)". Every one exited 0. Also,pnpm --filter @objectstack/spec check:generatedexited 0 ("All 15 generated artifacts are up to date").gen:migration-registrywas re-run.spec-changes.jsonand the upgrade guide do not project step 18, and regenerating them produced no diff.Lint, narrowed and declared:
pnpm exec eslint --no-inline-config --format jsonover the 6 touched.tsfiles gave 6 files linted, 0 errors, 0 warnings. The population is read fromeslint.config.mjs:**/*.{ts,…}, none of the files is inNEVER_LINTED, and each file came back with a result. The config enables no type-aware linting (noparserOptions.project), so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.git merge-tree --write-tree HEAD origin/mainis clean.registry.tsis not driver-routed, so this local reading matches GitHub's.Acceptance notes (boundaries, not filed)
defaultbesideback, or aconditional-typed edge with noconditionbeside adefaultone) are accepted under triage's "same edge type".traverseNexttreats them alike, so the target runs twice.labels out of a node that never selects a branch are accepted, and both run. A parse cannot know which node types select by label: approval is a plugin type, under ADR-0018's open namespace.edges[], not inside a region body. That is pre-existing and untouched here.MAX_REGION_DEPTH(32) a repeat is not judged. An endpoint there is still refused byanalyzeRegionatregisterFlow.Generated by Claude Code