Skip to content

Commit 12e2c8d

Browse files
lfagundesclaude
andcommitted
docs(openspec): reconstruct the missing connectivity-contracts record
ba6ff1e shipped the connectivity contracts with no OpenSpec cycle, and 77c3970 brought only the baseline specs level with the code. The change record itself was still absent, so the framework's history showed the promise without the reasoning, and the archive had no entry for work that is now in main. Add the proposal, design, and delta specs the cycle should have produced before implementation, and archive them. The deltas state exactly what those two commits landed and are archived with --skip-specs because the baseline already carries them; the proposal records that the record was reconstructed after the fact rather than presenting it as a cycle that ran in order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 77c3970 commit 12e2c8d

7 files changed

Lines changed: 332 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-08-08
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
## Context
2+
3+
The framework's geometric contracts were complete in one direction only.
4+
`assertNotIntersecting`, `assertFreeWithin` and `assertNoPairwiseIntersections`
5+
all answer "are these two things apart?", and a project can build a thorough
6+
suite out of them without ever asking "is this one thing whole?".
7+
8+
The failure that exposed the gap is cheap to produce and invisible to every
9+
existing check. A leaf's `render()` returns several solids; a backend "union"
10+
of solids that do not overlap does not fail, it hands back a compound; the
11+
resulting mesh is watertight because each shell is closed; `mesh.volume` is
12+
positive; the STL exports; the viewer draws something part-shaped. Three shop
13+
projects shipped that way (fan blades 3mm off the hub, a selector fork joined to
14+
nothing, label bars floating off a plate), and in each case the whole suite was
15+
green.
16+
17+
Two mechanisms were available and neither was in use: trimesh can split a mesh
18+
into connected components, and the build already walks the node tree before
19+
publishing.
20+
21+
## Goals / Non-Goals
22+
23+
**Goals:**
24+
25+
- Assertions that count connected bodies, including the inverse contract —
26+
two features that MUST fuse.
27+
- A build-time guard for the property, opt-in per node, that refuses to publish
28+
a model which arrives in pieces.
29+
- Zero cost for a project that does not ask for the check.
30+
31+
**Non-Goals:**
32+
33+
- Inferring intent. The framework does not guess that a node "should" be one
34+
body; a node says so.
35+
- Repairing geometry. A violation is reported, never welded automatically.
36+
- Changing the meaning of watertightness or any existing assertion.
37+
38+
## Decisions
39+
40+
**Count components with `only_watertight=False`.** The question is whether the
41+
geometry hangs together. Filtering to watertight components would drop exactly
42+
the evidence being sought, since the fragments in every observed defect were
43+
themselves closed shells.
44+
45+
**`bodies` defaults to `None`, not to 1.** One is the right contract for almost
46+
every printed part, but defaulting to it would make every existing project start
47+
loading meshes at build time and would fail assemblies that are legitimately
48+
multi-body. The declaration is opt-in; `assertNoDisconnectedParts` applies the
49+
"one body unless declared otherwise" reading in the test layer instead, where a
50+
project has chosen to ask.
51+
52+
**`FusionNode` declares `bodies = 1`.** Its docstring already promised "a
53+
single, inseparable unit". Declaring it turns that promise into something the
54+
build enforces, and a fusion of non-overlapping children — the exact way a part
55+
falls apart — now fails instead of publishing.
56+
57+
**Verify on both publication paths.** The natural place looks like STL
58+
completion, but a build that finds every artifact already current publishes too,
59+
without rendering anything. Checking only the rendering path would let a
60+
fragmented model reach the maker on the second build. `_verify_declared_bodies`
61+
therefore runs immediately before `_write_viewer_snapshot` on both paths, inside
62+
the existing try/except, so a violation is reported through `errors.json` like
63+
any other build failure and the previous publication keeps serving.
64+
65+
**`assertJoined` is the inverse of the adjacency rule.** Everywhere else,
66+
shared volume between two nodes is a defect. Within a single printed part it is
67+
the requirement, and tangential contact is not enough: solids fuse only where
68+
they overlap. `min_weld_volume` lets a drawing state how much overlap the
69+
junction needs, so a weld that exists but is too thin to print fails too.
70+
71+
## Risks / Trade-offs
72+
73+
- `verify_bodies()` reads a node's mesh, which costs a load. Confined to nodes
74+
that declare a count, so the cost is opted into.
75+
- A project that legitimately publishes a multi-body leaf must declare it, or
76+
leave it undeclared. This is intended: silence means unchecked, never
77+
"must be one".
78+
- Splitting very large meshes is not free; the check runs once per publication,
79+
not per assertion sweep.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
## Why
2+
3+
A rigid printed part is meant to be one connected solid, and nothing in the
4+
framework could tell whether it was.
5+
6+
Watertightness is a per-shell property. A mesh of several disjoint closed
7+
shells is watertight, has a positive volume, exports a valid STL, and renders
8+
in the viewer looking like a part. So a component whose features never actually
9+
reached each other passes every check the framework offers. Three shop projects
10+
shipped parts in pieces through that gap — a windmill fan whose four blades stop
11+
3mm short of their hub (4 bodies), a selector fork whose neck reaches neither
12+
the ring nor the carriage (2 bodies), a selector gate whose label bars float off
13+
the plate (14 bodies) — all watertight, all green.
14+
15+
The geometric pressure a project can express was also one-sided. Adjacency
16+
discipline (`assertNoPairwiseIntersections`) pushes parts apart, and a part that
17+
has fallen into fragments satisfies every non-interference contract there is.
18+
Nothing pulled the other way.
19+
20+
## What Changes
21+
22+
- Connectivity assertions over the number of connected solids in a node's
23+
world-space mesh: `assertOneBody`, `assertBodyCount`, `assertJoined`
24+
(with an optional minimum weld volume — the one case where two features are
25+
*required* to share volume), and `assertNoDisconnectedParts` as the tree-wide
26+
counterpart of the pairwise adjacency sweep.
27+
- Components are counted by splitting the mesh without filtering to watertight
28+
components, because a fragment that is itself closed is exactly the case
29+
worth catching.
30+
- Nodes MAY declare a `bodies` count. `verify_bodies()` raises
31+
`DisconnectedBodyError` naming the node, the declared count and the actual
32+
one. The default is `None`, so a project that does not ask for the check
33+
never loads its meshes on account of it.
34+
- `FusionNode` declares `bodies = 1`, making "a single, inseparable unit" a
35+
checked property rather than a docstring promise.
36+
- The builder verifies declared counts before publishing, on both publication
37+
paths — including the one that finds the artifact set already current, so a
38+
fragmented model cannot reach the maker by that route either. A violation
39+
prevents publication and is reported through the ordinary error channel.
40+
41+
## Capabilities
42+
43+
### New Capabilities
44+
45+
None. This adds contracts to the existing test framework, node model, and build
46+
pipeline.
47+
48+
### Modified Capabilities
49+
50+
- `test-framework`: the connectivity assertions, how bodies are counted, and
51+
the statement that watertightness is not evidence of connectedness.
52+
- `node-model`: the `bodies` declaration, its unchecked default, the
53+
`FusionNode` count, and what `verify_bodies()` raises and skips.
54+
- `build-pipeline`: verification before publication on both publishing paths, a
55+
violation reported through the ordinary error channel, and no mesh read for a
56+
project that declares nothing.
57+
58+
## Impact
59+
60+
- `solid_node/test.py``_body_count` helper and the four assertions.
61+
- `solid_node/node/base.py` — the `bodies` attribute, `verify_bodies()`, and
62+
`DisconnectedBodyError`.
63+
- `solid_node/node/fusion.py``bodies = 1`.
64+
- `solid_node/core/builder.py``_verify_declared_bodies()` called on both
65+
publication paths.
66+
- `tests/test_connectivity.py` — new; `tests/test_builder_lifecycle.py`
67+
extended for the publication paths.
68+
- Existing projects are unaffected until they declare a count or write a
69+
connectivity contract: the default leaves every node unchecked.
70+
71+
## Record note
72+
73+
This record was reconstructed after the fact. The implementation
74+
(`ba6ff1e`) shipped without an OpenSpec cycle, and the baseline specs were
75+
brought level with the code in a follow-up (`77c3970`) that still left the
76+
change record missing. This change directory carries the proposal, design, and
77+
delta specs the cycle should have produced before implementation; the delta
78+
specs state exactly what those two commits landed, and are archived with
79+
`--skip-specs` because the baseline already carries them. Nothing here
80+
describes an intention that was not implemented.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
## ADDED Requirements
2+
3+
### Requirement: A declared body count is verified before publication
4+
5+
Before publishing, a builder SHALL walk the loaded node tree and hold every
6+
node that declares a `bodies` count to it. Verification SHALL happen on every
7+
path that publishes, including the one that finds the artifact set already
8+
current, so a model that arrives in pieces cannot reach the maker by that
9+
route either. A violation SHALL prevent publication and SHALL be reported
10+
through the same error channel as any other build failure, leaving the
11+
previously published artifacts in place. Nodes that declare no count SHALL be
12+
skipped without their meshes being read, so the check costs nothing until a
13+
project asks for it.
14+
15+
#### Scenario: A fragmented model is not published
16+
17+
- **WHEN** a build completes rendering and a node's built mesh has a different
18+
number of connected solids than the node declares
19+
- **THEN** no viewer snapshot is published and the failure is reported through
20+
`errors.json`
21+
22+
#### Scenario: The already-current path is checked too
23+
24+
- **WHEN** a builder finds the published artifact set already current for a
25+
node that violates its declared body count
26+
- **THEN** it republishes nothing and reports the failure
27+
28+
#### Scenario: A project that declares nothing pays nothing
29+
30+
- **WHEN** a build runs on a tree in which no node declares a body count
31+
- **THEN** no mesh is read for this check and publication proceeds as usual
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Declared connected-body count
4+
5+
A node MAY declare, through a class-level `bodies` attribute, how many
6+
connected solids its own built mesh must have. The default SHALL be `None`,
7+
leaving the node unchecked so that a project which does not ask for the check
8+
never loads its meshes on account of it. `FusionNode` SHALL declare
9+
`bodies = 1`, making "a single, inseparable unit" a checked property rather
10+
than a docstring promise.
11+
12+
`verify_bodies()` SHALL raise `DisconnectedBodyError`, naming the node, the
13+
declared count, and the actual one, when a declared count does not match the
14+
number of connected components of the node's mesh. A node that declares no
15+
count, and a node that is not rigid, SHALL be skipped without its mesh being
16+
read.
17+
18+
#### Scenario: A fusion arrives in pieces
19+
20+
- **WHEN** `verify_bodies()` runs on a `FusionNode` whose children do not
21+
actually overlap
22+
- **THEN** it raises `DisconnectedBodyError` naming the node and its body
23+
count
24+
25+
#### Scenario: An undeclared node is not checked
26+
27+
- **WHEN** `verify_bodies()` runs on a node that leaves `bodies` at its
28+
default
29+
- **THEN** it returns without reading the node's mesh
30+
31+
#### Scenario: A non-rigid node is not checked
32+
33+
- **WHEN** `verify_bodies()` runs on a non-rigid node that declares a count
34+
- **THEN** it returns without reading the node's mesh
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Connectivity assertions
4+
5+
The system SHALL provide assertions over the number of connected solids in a
6+
node's world-space mesh, counted by splitting the mesh without filtering to
7+
watertight components — a fragment that is itself closed still counts as a
8+
body. Watertightness SHALL NOT be treated as evidence of connectedness: a mesh
9+
of several disjoint closed shells is watertight, has positive volume, and
10+
exports a valid STL.
11+
12+
- `assertOneBody(node)` — the node is a single connected solid.
13+
- `assertBodyCount(node, expected)` — the node has exactly `expected`
14+
connected components; the failure names the node, the expected count, and
15+
the actual one.
16+
- `assertJoined(node1, node2, min_weld_volume=0.0)` — the union of the two
17+
nodes' meshes is exactly one connected component, so the two features are
18+
genuinely the same printed part. `min_weld_volume` (mm³) additionally
19+
requires the volume they share to reach that value. Solids that only touch
20+
tangentially SHALL NOT count as joined.
21+
- `assertNoDisconnectedParts(node)` — the connectivity counterpart of the
22+
pairwise adjacency sweep: it walks the assembled tree to its leaves and
23+
holds every leaf to the count its `bodies` attribute declares, defaulting
24+
to one body when it declares none.
25+
26+
#### Scenario: A part in pieces is caught
27+
28+
- **WHEN** `assertOneBody` runs on a node whose features never reached each
29+
other, producing a watertight mesh of several closed shells
30+
- **THEN** an `AssertionError` names the node and the number of bodies its
31+
mesh has
32+
33+
#### Scenario: Tangential contact is not a join
34+
35+
- **WHEN** `assertJoined` runs on two solids that meet exactly on a face
36+
without overlapping
37+
- **THEN** the assertion fails, because their union is still two components
38+
39+
#### Scenario: A weld below the stated minimum
40+
41+
- **WHEN** two features overlap, but by less than `min_weld_volume`
42+
- **THEN** the assertion fails naming the weld volume and the required one
43+
44+
#### Scenario: Assembly-wide connectivity
45+
46+
- **WHEN** `assertNoDisconnectedParts` sweeps an assembly in which one leaf
47+
anywhere in the tree has fallen into fragments
48+
- **THEN** an `AssertionError` names that leaf
49+
50+
#### Scenario: A leaf that is deliberately several bodies
51+
52+
- **WHEN** a leaf declares `bodies = 2` and its mesh has two components
53+
- **THEN** the sweep passes for that leaf, and fails it if the mesh has any
54+
other number
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
## 1. Counting bodies
2+
3+
- [x] 1.1 Red: two disjoint closed shells are watertight and have positive
4+
volume — the premise the rest of the change rests on.
5+
- [x] 1.2 Add `_body_count`, splitting with `only_watertight=False` so a
6+
fragment that is itself closed still counts as a body.
7+
8+
## 2. Connectivity assertions
9+
10+
- [x] 2.1 Red: `assertOneBody` fails on a node whose features never reached
11+
each other, naming the node and its body count, and passes a single
12+
solid.
13+
- [x] 2.2 Red: `assertBodyCount` names both the expected and the actual count
14+
on failure.
15+
- [x] 2.3 Red: `assertJoined` fails two solids meeting exactly on a face
16+
without overlapping, and passes genuinely overlapping features.
17+
- [x] 2.4 Red: `assertJoined` fails a weld below `min_weld_volume` and passes
18+
one above it.
19+
- [x] 2.5 Red: `assertNoDisconnectedParts` fails when one leaf anywhere in the
20+
tree has fragmented, passes an all-single-body tree, respects a leaf that
21+
declares a legitimate count, and still enforces that declared count.
22+
- [x] 2.6 Implement the four assertions in `solid_node/test.py`.
23+
24+
## 3. Declared body counts on nodes
25+
26+
- [x] 3.1 Red: `AbstractBaseNode` declares no count by default and
27+
`FusionNode` declares one body.
28+
- [x] 3.2 Red: `verify_bodies()` passes a single body, raises
29+
`DisconnectedBodyError` on a fragmented result, and skips an undeclared
30+
node and a non-rigid node without reading their meshes.
31+
- [x] 3.3 Implement `bodies`, `verify_bodies()` and `DisconnectedBodyError` in
32+
`solid_node/node/base.py`, and `bodies = 1` in `solid_node/node/fusion.py`.
33+
34+
## 4. Build-time verification
35+
36+
- [x] 4.1 Red: a build whose node violates its declared count publishes no
37+
viewer snapshot and reports through `errors.json`.
38+
- [x] 4.2 Red: the already-current publication path performs the same check and
39+
republishes nothing on violation.
40+
- [x] 4.3 Implement `_verify_declared_bodies()` and call it before
41+
`_write_viewer_snapshot()` on both publication paths.
42+
- [x] 4.4 Confirm the lifecycle tests exercise the real `verify_bodies`, not a
43+
no-op stand-in, so the undeclared-node fast path stays honest.
44+
45+
## 5. Whole-system checks
46+
47+
- [x] 5.1 Run the full framework test suite.
48+
- [x] 5.2 Verify against the three real defects — the windmill fan, the
49+
selector fork, the selector gate — and confirm each goes red with its
50+
body count named.
51+
- [x] 5.3 Sync the baseline specs for `test-framework`, `node-model` and
52+
`build-pipeline`.

0 commit comments

Comments
 (0)