Skip to content

feat(spec)!: FlowSchema refuses an edge whose endpoint names no node of its graph, and a repeated edge - #22119

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22088-flow-edge-endpoints
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22088-flow-edge-endpoints

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22088

Clause-②: no (narrowing)

What changes

FlowSchema's superRefine gains 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 (via defineStack), defineFlow, AutomationEngine.registerFlow (which parses first), and the metadata save door in draft and in publish mode.

  1. An endpoint that names no node of the edge's own graph is refused at edges.N.source / edges.N.target. For a region edge the anchor is the region path, such as nodes.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.
  2. A repeated edge is refused at edges.N (the later copy), naming the earlier copy by position and id.

The issue is the same custom Zod issue the duplicate node-id and edge-id rules raise. That gives 422 INVALID_METADATA with located issues[] at the save door. No new error code.

Measured decisions (the dispatch's mechanism hypotheses)

  • H1, confirmed on aa71c4d9d. The superRefine refused 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. traverseNext looks the target up in the flow it walks, and runRegion runs 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:

    • a region edge naming a top-level node was already refused at registerFlow by analyzeRegion;
    • a top-level edge into a region node parsed, then routed nowhere at run time.

    Both are now refused at parse.

  • H3, what "repeated" means. It is the key the engine selects on in traverseNext, and nothing wider:

    • same source and target;
    • same type: a fault edge is followed only on failure;
    • same condition, compared by dialect and source, the two parts evaluateCondition reads. So a bare CEL string and its envelope are one condition;
    • same branch label: a decision or approval narrows its out-edges to the label it selected, so approve and reject may both reach one node.

    isDefault is deliberately not in the key. An unconditional edge and its isDefault copy are both taken whenever no conditioned sibling holds, so that pair is refused.

    ⚠️ label is one field more than triage's wording ("same edge type and condition"). Without it, an approval routing approve and reject into one node would be refused. The engine treats those as distinct branches. I flagged this for the contract review.

  • H4, write doors. saveMetaItem runs 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: promoteDraftForPublish does not re-parse. A draft stored before this change is still promoted, and is then refused at registerFlow: 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, through collectFlowGraphs, with a planted positive control that fired on every arm:

    • every flow the examples ship (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 a source and target at all;
    • the package-shipped flows are clean by reading: the os generate and os explain templates, the new-flow seed, the @objectstack/verify fixture, and the platform checklist's flow bodies;
    • the CLI golden eval corpus carries no flow (0 matches for flow in packages/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 is registered 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/spec minor with the BREAKING banner, because .changeset/pre.json is absent on origin/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):

    package files result
    metadata-protocol 27 696 passed
    objectql 15 501 passed
    rest 6 350 passed, 8 skipped
    lint 14 1411 passed
    service-automation (full suite) 174 2116 passed
  • pnpm --filter @objectstack/spec typecheck exit 0; pnpm --filter @objectstack/metadata-protocol typecheck exit 0.

  • Fixture triage. One fixture was re-judged, none batch-rewritten. The node-id test "raises one issue per later occurrence" shared start → n → end edges 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.zod from src (no dist on the path). Each mutation went through scripts/ablation-replace.mjs, which checks that the anchor hits 1 time and goes to 0 after the edit:

    • endpoint rule disabled: 6 of the 12 new cases red, 6 green (the repeat cases and the controls);
    • repeat rule disabled: 4 red, 8 green;
    • baseline: 12 green;
    • restore proven: the file's blob is 6f05aaec again, equal to HEAD, and git diff HEAD is 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 --ran reports "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:generated exited 0 ("All 15 generated artifacts are up to date"). gen:migration-registry was re-run. spec-changes.json and 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 json over the 6 touched .ts files gave 6 files linted, 0 errors, 0 warnings. The population is read from eslint.config.mjs: **/*.{ts,…}, none of the files is in NEVER_LINTED, and each file came back with a result. The config enables no type-aware linting (no parserOptions.project), so this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is CI's.

  • git merge-tree --write-tree HEAD origin/main is clean. registry.ts is not driver-routed, so this local reading matches GitHub's.

Acceptance notes (boundaries, not filed)

  • Two edges into the same pair with different non-fault types (default beside back, or a conditional-typed edge with no condition beside a default one) are accepted under triage's "same edge type". traverseNext treats them alike, so the target runs twice.
  • Two edges with different 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.
  • Edge ids are still held unique only on the top-level edges[], not inside a region body. That is pre-existing and untouched here.
  • Past MAX_REGION_DEPTH (32) a repeat is not judged. An endpoint there is still refused by analyzeRegion at registerFlow.
  • The designer half (objectui#11772) lands independently. Until it does, a Studio draft carrying these edges answers 422 at save, with the edge located.

Generated by Claude Code

claude added 4 commits October 7, 2026 17:50
…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>
@github-actions github-actions Bot added the size/l label Oct 7, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 6 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via FlowSchema (symbol, a top-level const))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via FlowSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-4.mdx (via FlowSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-7.mdx (via FlowSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 6 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 — 139 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 a543e244f038c81e7b36d93345ace72041f7c5e0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4e0967cc7f2dada67928950d9f6d4186b429ee74 — the merge of head a4ababd1da76a7fddc82bc2a0d0ff4b6372716b9 into base a543e244f038c81e7b36d93345ace72041f7c5e0, 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 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

⚠️ 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 a543e244f038c81e7b36d93345ace72041f7c5e0 → 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: a4ababd1da76a7fddc82bc2a0d0ff4b6372716b9
Local-runs: none

Inputs, and nothing else: card #22088 (body and all five comments; triage grade 6041927874 is the ruling; the seat's ACCEPT 6044475647 was tested as a claim, not adopted); PR #22119 (body, 7-file list, +644/−8, net diff against main at the head); every source cited below read with git show off origin/main (a543e244f) and the PR head; the head's check-runs. Nothing built, tested, re-gated or ablated; the one git plumbing call beyond show/diff/ls-tree was a read-only git merge-tree --write-tree origin/main a4ababd1d (no worktree), declared under ③.5.

Check-runs on the head, read 2026-10-07T18:51Z — 36 runs: completed/success 23 (Build Core, Governed Surface Queue Guard, Check Changeset ×2, Spec property liveness, Dogfood Regression Gate 1/3, Dogfood Verify CLI, Type Check · consumer gates / debt ledger / source gates, Check PR Size, Auto Label, filter, the four claim/branch/closing-keyword guards, Check Documentation Links, Flag docs affected); completed/skipped 6 (Build Docs, Console Pin Gate, Packed-tarball smoke ×2, Auto Label, Check PR Size); failure 0; in_progress 10: Lint & Repo Gates, Type Check · workspace, Test Core 1/6–6/6, Dogfood Regression Gate 2/3 and 3/3, Temporal Conformance (live PG + MySQL). in_progress is recorded as what it is, not a pass; the landing stays held until every gate-carrying run concludes success, as the seat's own hold already says. An earlier read at 18:44Z had 19 in_progress and the same 0 failures.

① Derived judgments

1. Refusal A — an endpoint that names no node of the edge's own graph: RIGHT. Judged per graph over collectFlowGraphs (top-level edges against the flow's nodes[]; a region edge against its body's nodes), anchored at edges.N.source / edges.N.target (the region path for a region edge), naming the missing id and the graph it does live in. Matches the engine: traverseNext resolves the target with flow.nodes.find by edge.target on the graph it walks and runRegion walks a view of its own nodes and edges, so a cross-graph endpoint routes nowhere. analyzeRegion already refused the region half at registerFlow; the top-level half had no refusal anywhere. Indexing over the authored list (authoredEdgesAt) keeps the anchor correct when a raw region drops a non-record member — pinned.

2. Refusal B — a repeated edge: RIGHT, with one dimension wider than the engine's (point 3). Key [source, target, type ?? 'default', condition(dialect, source), label ?? null], refused at edges.N on the later copy naming the earlier, judged only between edges whose endpoints both resolve. Same custom Zod issue as the id rules, so the save door's 422 INVALID_METADATA carries it with no new code.

3. The key against traverseNext (engine.ts on origin/main, the selection and execution halves):

  • source: allOutEdges is flow.edges filtered to e.source === node.id && e.type !== 'fault' — in the key, right.
  • label: BELONGS. When branchLabel is set, claimed is outEdges filtered to e.label === branchLabel — a decision / approval / resume signal narrows to the label, so approve and reject into one node are two selections, never one traversal. Dropping it (open question 2, option B) would refuse a legitimate approval shape. Residual: a node that never returns a branch runs every label-distinct edge; a parse cannot know which node types select (ADR-0018 open namespace) — the acceptance note stands.
  • condition: by dialect + source — evaluateCondition reads source alone; parsed edges are already envelopes, a raw region's bare string keys as ['cel', s]. Right.
  • isDefault: correctly NOT in the key. Edges bucket as condition → conditional, else isDefault → default, else unconditional; an unconditional edge runs on every pass, its isDefault twin whenever no conditional sibling held — the pair has a double-run path on every traversal where no condition holds and no traversal where the twin adds a route the unconditional edge does not already take. Refusing it is right; the objectui note's "differ only by isDefault = different routes" is wrong against this engine, and the spec-side key is the one both halves should read.
  • type: the engine reads type ONLY as fault / not-fault at traversal (back is read by cycle detection alone). The shipped key carries the full enum, so three same-pair shapes still pass and double-run: default beside back (largely unreachable — the default copy closes the cycle detectCycles refuses at registerFlow), conditional with no condition beside default, and conditional with condition X beside default with condition X (not in the dev's notes). Triage's wording "same edge type and condition" licenses the key as shipped; it under-refuses and never over-refuses, so no legitimate flow is refused and the fix is additive to the same walk (type === 'fault' as the type dimension). Not blocking; escalated under ③.

4. Every door, read in source:

  • FlowSchema (the superRefine); defineFlow → FlowSchema.parse (flow.zod.ts:1591); defineStack → flows: z.array(FlowSchema) (stack.zod.ts:457) → STACK_SCHEMA_INVALID 422 for the whole stack.
  • os validate / os compile: loadConfig runs the defineStack producer (validate.ts:242–251, compile.ts:275–284), then ObjectStackDefinitionSchema.safeParse over the lowered stack (validate.ts:341, compile.ts:396); os migrate meta (meta.ts:642) and the lint score (score.ts:89) parse the same way. Those are the "artifact refused whole" doors.
  • Runtime artifact load: load-artifact-bundle.ts parses nothing against FlowSchema and artifact-collections.ts uses the stack schemas for key shapes only; an artifact's flows meet FlowSchema at registerFlow through the boot pull — per flow, not whole. See ③ advisory (a).
  • AutomationEngine.registerFlow → canonicalizeStoredFlow → applyConversionsToStoredItem (full chain) → FlowSchema.parse(converted) (engine.ts:4348) throws; the three boot paths catch and warn (plugin.ts:1324 boot pull, :2447 re-sync, :2513 cold-boot bind, which also withdraws a same-name boot-pull registration): skipped, trigger not armed, flows beside it register. Functional degradation → warn is the right level.
  • Metadata save door: saveMetaItem resolves getMetadataTypeSchema('flow') = FlowSchema (metadata-type-schemas.ts:121) and safeParses the body with no mode guard — draft and publish alike → 422 INVALID_METADATA, located issues[]. The flow-canonicalize pass that runs first throws on the new refusal, is caught, falls back to the raw body and lets the gate refuse (protocol.ts ~20420–20545). Pinned on the real protocol path in both modes with a CONTROL that stores. promoteDraftForPublish does not re-parse (read, not pinned): a pre-change draft promotes and is then refused at registerFlow — the stored-flow case, ruled in 5.
  • Fixture triage: one spec fixture re-judged (its shared start → n → end edges named no node of its own list — never well-formed under the rule); no consumer fixture moved. Corpus 35 flows / 55 graphs / 131 edges at aa71c4d9d, 0 hits, positive control fired — a reading with its tree; taken as reported, not re-measured.

5. ADR-0087 disposition for stored flows — RULE A, as shipped: registered D3 flow-edge-unresolved-or-repeated-refused, no D2.

  • ADR-0087 D2 is the lossless conversion whose canonical target the platform KNOWS ("most breaks require zero consumer action"; what "cannot be converted … goes to D3"; "D2 carries the mechanical data repair only"). A dangling endpoint has no recoverable target. A repeated copy has no behaviour-preserving rewrite — the only shape that preserves "run three times" is the copies, which the parse now refuses — and dropping a copy changes the run count, which is the author's judgment: D3's territory.
  • Rule B's D2 is a default-flip-class behaviour change at a load seam. The engine's own CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION and the A decision node with no declared config.conditions takes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429 letter-C precedent (flow-decision-edge-branching-first-match: a stored flow is OUTSIDE the conversion, takes the new meaning on upgrade, BREAKING; os migrate meta --stored lists candidates without writing them) hold that such a rewrite replays only where age is asserted (os migrate meta --from 17), never at canonicalizeStoredFlow, because a row written yesterday against the new contract is byte-identical to one written before it. A B-style D2 would therefore be excluded from exactly the stored seam the question is about, or rewrite new rows too. Incoherent; refused.
  • Step-18 precedents for flow parse narrowings — flow-builtin-node-config-values-refused, flow-approval-node-config-contract-refused, flow-decision-branch-expression-absent-refused, structured-region-body-pause-and-end-refused, flow-edge-condition-evaluated-slot-source-required — are all D3-only with the identical stored posture (skipped at boot with the failed to register flow warn, trigger not armed). The entry matches them; the launch-window exemption (ADR-0087 addendum of 2026-07-15: one-step without a load window, one D3 entry per family in the same release) is satisfied.
  • Consequence weighed: a deployed automation that double-ran stops at boot LOUDLY, with a warn naming it and a one-line fix, rather than having its run count changed silently — the ADR's and PD Add comprehensive test suite for Zod schema validation #12's preferred failure; corpus 0 hits; os migrate meta --stored runs the same canonicalizeStoredFlow and fails such a row loudly, a second locator (advisory (b)).
  • Rule C (not-required) is wrong: the body carries FROM → TO and @objectstack/spec publishes; check-adr-0087-registration refuses no-migration-prescription on that shape.

6. Ledger mechanics: entry file 18.flow-edge-unresolved-or-repeated-refused.ts, id = filename; generated-region insertion sorted by id (between flow-edge-condition-… and flow-node-config-…); STEP18_RATIONALE fragment order: 86 is unique (the duplicate orders 56/60/62/66/67/74/77 pre-exist on main); surface carries no backticks or pipes; PROTOCOL_MAJOR is 17, so spec-changes.json and the upgrade guide do not project step 18 — the dev's "no diff" is expected, not lucky. main has since gained 18.cbp-master-detail-required-lint-error.ts with its generated lines; different ids insert at different lines by construction and check:migration-registry re-proves the merged region in the queue.

7. Public surface: no export added or removed (authoredEdgesAt, edgeConditionKey are module-private), no .describe() or authorable-key change, control-flow.zod.ts comment-only — so no api-surface / docs / authorable-surface artefact owes a regeneration. No governed surface in the file list (Governed Surface Queue Guard: success).

② Semver level

  • .changeset/22088-flow-edge-unresolved-or-repeated-refused.md: '@objectstack/spec': minor; headline feat(spec)!:; Clause-②: no (narrowing) at line start; **BREAKING** banner; FROM → TO table and the one-line fix (with the conditioned-edge exception); one adr-0087: registered flow-edge-unresolved-or-repeated-refused marker whose id is new in this diff. Each sentence read against the diff; it matches what the diff publishes.
  • Level: git ls-tree origin/main .changeset/ at a543e244f — 47 entries, no pre.json → the launch-window convention binds: minor + BREAKING banner + disposition, ⛔ not major (check-changeset-no-major would refuse it); matches triage's grade and ADR-0087's level amendment.
  • Packages: the diff publishes a change from @objectstack/spec alone; packages/metadata-protocol moves a test file only — no second entry owed. The fixed group bumps in lockstep regardless.
  • Clause-②: no (narrowing)

③ Boundary flags

Report 6044439981 — every deviations entry, open_questions entry and out_of_scope_findings entry:

Deviations

  1. Body Clause-②: no (narrowing) over the claim's Clause-②: no — ANSWERED, accepted: triage's grade itself spells "Clause-②: no (narrowing)"; the arm is the honest spelling of a narrowing (clause2-line.mjs: not a widening, breaking); body and changeset carry one line.
  2. label added to triage's "type and condition" — ANSWERED, accepted (①.3). The type dimension is the one that is wider than the engine's, escalated in (a) below.
  3. Base spec builds timed out in the verify-lock queue; base census from source via tsx — ANSWERED, accepted: the census input (examples/**) is untouched by the diff and collectFlowGraphs is read from source either way; the reading is anchored at aa71c4d9d. Not re-measured here (read-only).
  4. Lock wrapper replaced and orphan killed — ANSWERED: hygiene inside the dev's own box; nothing of it is on the PR; not a contract matter.
  5. origin/main not merged — ANSWERED, accepted: re-checked read-only against current origin/main a543e244f (8 commits ahead of the base): git merge-tree --write-tree clean, exit 0; the only overlap is the generated region of registry.ts, where two ids insert at distinct sorted positions; the queue rebuilds on current main and check:migration-registry re-judges. A red there is a regenerate-and-re-arm lap, not a contract matter.
  6. Model-free commit trailers and the session-URL PR footer over the harness reminder's forms — ANSWERED: AGENTS.md's form is the repo's rule, and the reminder yields to it by its own text.
  7. Worktree removed after the PR opened — ANSWERED: no effect on the PR; the head is on the remote.

Open questions

  1. Stored-flow disposition — RULE A, as shipped (①.5).
  2. label in the repeat key — option A, keep it (①.3).

Out-of-scope findings (carrier 承接者:无)

  • (a) Same pair, different non-fault types still double-run — ESCALATED for a follow-up card (the seat's write, not this reviewer's): the key's type dimension should be the engine's own discriminator, fault / not-fault; the card should carry the third shape named in ①.3 (conditional with condition X beside default with condition X). Not blocking: under-refusal only, additive fix in the same walk.
  • (b) Label-distinct edges out of a node that never selects by label — ANSWERED: acceptance note stands (ADR-0018 open namespace).
  • (c) Edge-id uniqueness judged on the top-level edges[] only — ESCALATED as a card candidate: the existing edge-id rule's claim is declared and not enforced inside region bodies (PD chore: version packages #10); pre-existing, untouched here.

Reviewer's advisories, none blocking

  • (a) The D3 entry's reason says "an artifact file is refused whole at load". True of the CLI's ObjectStackDefinitionSchema.safeParse doors (os validate, os compile, os migrate meta); at RUNTIME the artifact load parses nothing against FlowSchema and the refusal is the per-flow skip-with-warn the same sentence also describes (①.4). The prescription and the boot-warn locator are right; tighten the clause in a follow-up edit of the entry file (the registry regenerates).
  • (b) acceptanceCriteria names the three boot warn lines as the locator for a sys_metadata-only row; os migrate meta --stored fails the same row through the same canonicalizeStoredFlow and is worth naming beside them.
  • (c) Pre-existing, now reached more often: the save door's canonicalize fallback prints flow/NAME was saved WITHOUT canonicalization … The body was persisted as submitted once per flow name per process BEFORE the type-schema gate refuses the save, so a refused save logs as a persisted one (protocol.ts ~20446–20470). A reproducible defect in metadata-protocol, not this card's; a card candidate under PD chore: version packages #10.

Landing: this record is the PASS the seat's hold names; the other half of that hold — every gate-carrying check-run success on this head — is not asserted here (10 still in_progress at the reading above).

Implemented-by: claude/issue-22088-flow-edge-endpoints
Reviewed-by: session_01RPo7FUd6bSnAfkWMAKi848

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 7, 2026 19:25
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 7, 2026 19:25
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit db4c45b Oct 7, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22088-flow-edge-endpoints branch October 7, 2026 20:03
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/l tests tooling

Projects

None yet

2 participants