You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(spec)!: `FlowSchema` refuses a flow whose `edges[]` declares the same id twice (#14964)
6
+
7
+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is renamed, retired or re-typed: `edges[].id` keeps its name, its type and its describe, and every flow whose edge ids are unique parses byte-identically. The only newly refused shape is two edges sharing one id — a collision, not a spelling — and its remedy is to renumber one of the two, which is authoring intent no `objectstack migrate meta` rewrite can choose for the author. The Zone-2 census over this repo (776 `edges[]` arrays, 1,098 edges under `packages/**` and `examples/**`, with a lit control) found zero instances, so there is no in-repo file to name. -->
8
+
9
+
**BREAKING** accept-set narrowing on `FlowSchema` — a flow whose `edges[]`
10
+
carries two edges with the same `id` is now **refused at parse time** — by
11
+
`FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that validates a
12
+
flow through the schema (`objectstack validate`, the runtime publish gate, a
13
+
stack's `flows[]`) — where it used to parse on green. Shipped as `minor` under
14
+
the repo's launch-window convention for breaking changes. Maintainer ruling
15
+
2026-09-05 on #14964 (director decision batch #40, verbatim 「同意」): option
16
+
A — an `error`, not a `warning`; no opt-out, no transition window.
17
+
18
+
Every reader of an edge id assumes the ids in a flow are unique — a designer,
19
+
a BPMN export, a flow diff, any traversal that dedupes by id — and nothing
20
+
enforced it. A real duplicate (`id: 'e20'` on two edges of one flow) shipped
21
+
through two releases of green CI in a downstream app and was inert only
22
+
because the engine keys out-edges by `source`, never by `id`: the collision is
23
+
invisible until something keys on ids, and then silently wrong rather than
24
+
loudly broken. The id space is hand-authored, so the next author picking a
25
+
"free" id from the sequence had no way to know it was taken.
26
+
27
+
**What changes** (`packages/spec/src/automation/flow.zod.ts`): a `superRefine`
28
+
on the flow's `edges[]`. Each later occurrence of an already-declared id raises
29
+
one `custom` issue, anchored at `edges[N].id` of the *later* edge and naming
30
+
both positions, so the formatted error points at the edge to renumber:
31
+
32
+
```text
33
+
✗ edges.7.id: Duplicate edge id `e20` — `edges[7]` reuses the id already declared by `edges[3]`; every edge id in a flow must be unique. Renumber one of them: …
34
+
```
35
+
36
+
**What does NOT change:**`edges[].id` keeps its name, type and describe; the
37
+
node vocabulary, the edge `type` enum and every other refusal are untouched;
38
+
a flow with unique edge ids (or no edges) parses exactly as before. Node ids
39
+
are not covered by this change.
40
+
41
+
The shape that is refused, and what the author does about it — a two-edge
42
+
excerpt, the later edge renumbered:
43
+
44
+
```ts
45
+
// before — parsed on green, both edges keyed 'e20'
No authoring rule throws on a non-record entry of any stack collection.
6
+
7
+
A collection is authored either as a list or as a name-keyed map, so every rule that reads one coerces `unknown` into an array of records first. That coercion had been hand-copied into 39 modules, and 23 of the copies spelled the array branch as an unchecked cast — every member was asserted to be a record. A YAML list item left empty deserialises to `null`, so a single stray `-` under `flows:`, `pages:`, `dashboards:`, `datasets:`, `apps:`, `permissions:`, `capabilities:`, `data:`, `hooks:`, `views:`, `actions:`, `translations:` (or a per-object `fields:` / `actions:` / `views:`) reached a property read on `null` and threw a stack trace out of `os lint` / `os validate` instead of reporting a finding. The rules are pure `(stack) => Finding[]` running on the raw path, so nothing upstream had judged the entry's shape.
8
+
9
+
Twenty-two of those readers now read through the shared, guarded `recordsOf`, which drops a non-record member of the array shape whole and keeps the author's key on the map shape. Nothing else about what the rules judge changes: a valid entry standing beside a junk one is still read, and still draws exactly the findings it drew before.
10
+
11
+
The remaining copies are pinned by a new source-text test in the package, so the predicate cannot be pasted back in: it asserts that `recordsOf` is the only collection coercion, that every module still holding a private one is named in a dated ledger that is exact in both directions, and that no coercion outside a dated single-file allowance casts its array branch unchecked.
`os serve` now says so when the SQLite file it is serving is no longer the file at its configured path.
6
+
7
+
Deleting the data directory under a running server — `rm -rf .objectstack/data`, which is what a `demo:reset` script does and what a fresh-database repro starts with — unlinks the inode without touching the process. SQLite keeps reading and writing the now-invisible file, health keeps answering `200`, and a later boot creates a brand-new database at the same path. From that moment every filesystem inspection of that path describes a *different* database than the running server answers from, and nothing anywhere says so: a row edited there has no observable effect on the live server, and a user who authenticates against the live server is not in that file. Both readings are true, both look like a broken write path, and one investigation that reported them as evidence cost a full P0 cycle.
8
+
9
+
A boot that serves an on-disk SQLite file now records that file's identity once the boot is complete and re-checks it on a 30-second interval. When the file is gone, or the path holds a different file, it reports **once** at `error` — naming the path, the consequence (every external observation of this deployment is now false, and it will keep looking healthy) and the fix (restart the server so it opens the file that is at that path now).
10
+
11
+
It refuses nothing and retries nothing: the running server is still correct, merely invisible, and breaking a working dev loop to fix a reporting gap would trade a bad hour for a worse one. Nothing is added to any payload, endpoint or state file. Silence from the check is not a claim that the file is intact — every uncertainty in it resolves toward staying quiet, because a false report would send an operator to restart a server whose database is fine.
0 commit comments