boundry 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,54 @@ All notable changes to Boundry are documented here.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
5
5
  versioning follows [semver](https://semver.org/).
6
6
 
7
+ ## [0.5.0] — 2026-07-21
8
+
9
+ ### Fixed
10
+
11
+ - **`approve` no longer leaves a stale, corrupted `boundry.diff.likec4`** ([#6]).
12
+ `approve` used to walk the derived diff file along with the model and splice the
13
+ marker tokens out of its *view rules* (`style element.tag = #proposed …`,
14
+ `include … where tag is #proposed`), mangling them into invalid LikeC4 — so
15
+ `likec4 validate` (and any `arch:verify` gate) went red immediately after a
16
+ successful approve, recoverable only by a separate `diff` run. `approve` now
17
+ skips the derived file and deletes it as part of enacting: the moment the last
18
+ proposal is approved those views are stale — they frame changes that are now
19
+ approved — so the post-approve workspace is clean and validates.
20
+
21
+ ### Added
22
+
23
+ - **Per-altitude diff views** ([#7]). `boundry diff` now emits a focused view for
24
+ *every* layer that draws a changed edge, not only the common-ancestor layer. A
25
+ cross-system dependency between two deeply-nested leaves is drawable at each
26
+ endpoint's system and container as well as at the shared root; `diff` emits one
27
+ `view … of <scope>` for each, so a reviewer can open the change at whatever
28
+ altitude matters — the whole system, a specific container — instead of being
29
+ dropped at the noisy root view. The scopes are derived from how LikeC4's
30
+ `include *` actually renders: the common ancestor plus each endpoint's ancestor
31
+ chain down to its parent, never *above* the common ancestor (where both
32
+ endpoints collapse into one child and the edge becomes an undrawn self-loop) and
33
+ never the leaf endpoints themselves. Boxes are unchanged — one view at the box's
34
+ parent layer.
35
+
36
+ ### Changed
37
+
38
+ - **`verify` now compares against the accepted `boundry.lock`, not a git ref.**
39
+ The lock was introduced in 0.4.0 to decouple *approved* from *committed*, but
40
+ `verify --base <ref>` kept re-deriving its baseline from a diagram at a git ref
41
+ — quietly re-coupling "accepted" to "committed", the very thing the lock exists
42
+ to break. `verify` now reads the lock, exactly as `annotate` does, so the two
43
+ share one baseline and can never disagree about what "accepted" means. It also
44
+ catches a committed-but-*unapproved* bare edge, which the git-ref baseline
45
+ silently absorbed. The `--base` flag is gone from `verify` and `approve`;
46
+ `approve`'s anti-laundering pre-check now runs the same lock-based gate, and the
47
+ first `approve` (no lock yet) bootstraps the initial accepted state. **Tradeoff,
48
+ by design:** the baseline is the working-tree lock, so the gate leans on
49
+ `approve` being a human act — the lock moves only when someone approves, which
50
+ is why the skill forbids agents from running it.
51
+
52
+ [#6]: https://github.com/makspiechota/boundry/issues/6
53
+ [#7]: https://github.com/makspiechota/boundry/issues/7
54
+
7
55
  ## [0.4.0] — 2026-07-17
8
56
 
9
57
  Makes the "one file can be both the communication diagram and the enforcement
package/README.md CHANGED
@@ -227,15 +227,21 @@ deterministically, by splicing the LikeC4 CST. No model call, no reformatting:
227
227
  source-preserving, idempotent, byte-exact.
228
228
 
229
229
  ```bash
230
- boundry verify --arch arch --base origin/main # any edge granted without a marker?
231
- boundry approve --arch arch --base origin/main # HUMAN ONLY: strip markers = approve
230
+ boundry verify --arch arch # any edge granted without a marker, vs the lock?
231
+ boundry approve --arch arch # HUMAN ONLY: strip markers = approve, update the lock
232
232
  ```
233
233
 
234
- `verify` compares against the approved diagram at a git ref and rejects edges that
235
- appeared *without* going through a proposal. Because proposals are excluded from
236
- the allow-list, the newly-allowed set is exactly the set of self-approvals — no
237
- diff engine required. `approve --base` runs the same gate first, so it won't
238
- launder a self-granted edge into an approved one.
234
+ `verify` compares the working diagram against the accepted **`boundry.lock`** and
235
+ rejects edges that appeared *without* going through a proposal. Because proposals
236
+ are excluded from the allow-list, the newly-allowed set is exactly the set of
237
+ self-approvals — no diff engine required. `approve` runs the same gate first, so
238
+ it won't launder a self-granted edge into an approved one; then it enacts the
239
+ proposals and records the new accepted state to the lock.
240
+
241
+ The baseline is the lock, not a git ref: it's the state Boundry *owns*, so
242
+ "accepted" never collapses into merely "committed". (One consequence, by design:
243
+ the gate leans on `approve` being a human act — the lock moves only when someone
244
+ approves. That's why the skill forbids agents from running it.)
239
245
 
240
246
  **To retire an edge or a box, propose its removal** with `#proposal-delete`
241
247
  instead of deleting it. The marker colours it red; a pending deletion changes
@@ -258,12 +264,12 @@ Point your agents at
258
264
  [`.claude/skills/define-architecture-boundaries`](.claude/skills/define-architecture-boundaries/SKILL.md)
259
265
  and they'll follow this protocol.
260
266
 
261
- ### Catching drift — the lock and `annotate` (prototype)
267
+ ### Catching drift — `annotate` (prototype)
262
268
 
263
- `verify --base` trusts git for the baseline, which conflates *approved* with
264
- *committed*. If you'd rather Boundry own that baseline: `approve` records the
265
- accepted model to **`boundry.lock`** beside the diagram, and `annotate` compares
266
- against it no git ref required.
269
+ `verify` catches a self-grant and fails; `annotate` is the other half it
270
+ *rewrites* the same drift into a reviewable proposal. Both read the same baseline,
271
+ the accepted **`boundry.lock`** that `approve` records beside the diagram, so they
272
+ can never disagree about what "accepted" means.
267
273
 
268
274
  ```bash
269
275
  boundry approve --arch arch # enact proposals AND write boundry.lock
@@ -291,8 +297,8 @@ outright. Requires **LikeC4 ≥ 1.58** to render.
291
297
 
292
298
  A proposal nested inside a box is invisible at a wider zoom: at the top level the
293
299
  box collapses and its inner `#proposed` edge disappears. So `diff` generates a
294
- **focused view for every layer that holds a pending change** — the tightest scope
295
- that actually draws it — into a derived `boundry.diff.likec4`:
300
+ **focused view for every layer that draws a pending change** — into a derived
301
+ `boundry.diff.likec4`:
296
302
 
297
303
  ```bash
298
304
  boundry diff --arch arch # (re)write boundry.diff.likec4
@@ -307,11 +313,20 @@ styling step. Boxes are styled *in place* (never force-included), so a nested
307
313
  proposal stays in its own layer; unchanged elements keep their defaults; and the
308
314
  rules live only in the generated views, so your own views are untouched.
309
315
 
316
+ A changed edge is drawn at **every altitude it appears**, not only the
317
+ common-ancestor layer — so a cross-system dependency between two deeply-nested
318
+ leaves gets a view at each endpoint's system and container as well as at the
319
+ shared root. Open the change at whatever altitude matters (the whole system, a
320
+ specific container) instead of being dropped at the noisy root view. `diff` emits
321
+ only the layers that genuinely draw the edge: never above the common ancestor
322
+ (where it collapses to an undrawn self-loop), never the leaf endpoints themselves.
323
+
310
324
  The file is a **derived artifact**: overwritten every run, removed when nothing is
311
- proposed, so it always matches the current diagram regenerate it after
312
- `approve`. It reads the diagram's own markers (not the lock), so it frames
313
- whatever `annotate` or a human has marked. Being derived, it's a `.gitignore`
314
- candidate (`boundry.diff.likec4`).
325
+ proposed, so it always matches the current diagram. `approve` deletes it too, as
326
+ part of enacting the moment the last proposal is approved the views are stale,
327
+ so the post-approve workspace validates clean. It reads the diagram's own markers
328
+ (not the lock), so it frames whatever `annotate` or a human has marked. Being
329
+ derived, it's a `.gitignore` candidate (`boundry.diff.likec4`).
315
330
 
316
331
  > Rendering the coloured diff views needs **LikeC4 ≥ 1.58** (the style-rule syntax
317
332
  > Boundry emits). Boundry itself depends on that floor; the tool you review with
@@ -322,8 +337,8 @@ candidate (`boundry.diff.likec4`).
322
337
  ```
323
338
  boundry check [--arch <dir>] [--cwd <dir>] [sources...]
324
339
  boundry generate [--arch <dir>] [--cwd <dir>] [--out <file>]
325
- boundry verify [--arch <dir>] [--cwd <dir>] --base <git-ref>
326
- boundry approve [--arch <dir>] [--cwd <dir>] [--base <git-ref>]
340
+ boundry verify [--arch <dir>] [--cwd <dir>]
341
+ boundry approve [--arch <dir>] [--cwd <dir>]
327
342
  boundry annotate [--arch <dir>]
328
343
  boundry diff [--arch <dir>]
329
344
  ```
@@ -333,15 +348,16 @@ boundry diff [--arch <dir>]
333
348
  | `--arch <dir>` | LikeC4 workspace directory (all `.likec4` files in it are merged). Default `.`. |
334
349
  | `--cwd <dir>` | Repo root to check. `folder` paths are relative to it. Lets you run from anywhere. |
335
350
  | `--out <file>` | `generate` only: where to write the dependency-cruiser config. Default `.dependency-cruiser.cjs`. |
336
- | `--base <ref>` | `verify`/`approve`: the git ref holding the approved diagram to compare against. |
337
351
  | `sources...` | `check` only: paths to lint. Default `src`. |
338
352
 
339
353
  - **`check`** compiles the rules and runs the linter. Exits non-zero on any violation.
340
354
  - **`generate`** just emits the dependency-cruiser config so you can commit it or
341
355
  run `depcruise` yourself.
342
- - **`verify`** rejects dependencies granted without a proposal.
343
- - **`approve`** enacts markers (strips `#proposed`, removes `#proposal-delete`) and
344
- writes `boundry.lock`. For humans, not agents.
356
+ - **`verify`** rejects dependencies granted without a proposal, comparing the
357
+ diagram against the accepted `boundry.lock`. Needs a lock — run `approve` once
358
+ to record one.
359
+ - **`approve`** runs the same gate, then enacts markers (strips `#proposed`,
360
+ removes `#proposal-delete`) and writes `boundry.lock`. For humans, not agents.
345
361
  - **`annotate`** rewrites undeclared additions as `#proposed`, diffing against
346
362
  `boundry.lock`.
347
363
  - **`diff`** generates a focused, colour-coded review view per layer that holds a
@@ -366,9 +382,8 @@ jobs:
366
382
  with: { node-version: 20 }
367
383
  - run: npm ci
368
384
  - run: npx boundry check --arch arch src
369
- # On PRs, also reject dependencies granted without a proposal.
370
- - run: npx boundry verify --arch arch --base origin/${{ github.base_ref }}
371
- if: github.event_name == 'pull_request'
385
+ # Reject any dependency granted without a proposal, vs the committed lock.
386
+ - run: npx boundry verify --arch arch
372
387
  ```
373
388
 
374
389
  ## Programmatic use (SDK)
@@ -18,6 +18,13 @@ export declare class LikeC4Visualizer implements VisualizerPort {
18
18
  * - `#proposal-delete` markers take their whole edge or box with them, so
19
19
  * approving a proposed removal actually removes it.
20
20
  * All other formatting is left intact.
21
+ *
22
+ * The derived `boundry.diff.likec4` is skipped, not approved: it holds no real
23
+ * markers, only view rules that *reference* the marker tags (`style element.tag
24
+ * = #proposed`, `include … where tag is #proposed`). Splicing those `#proposed`
25
+ * tokens out would corrupt the rules into invalid LikeC4. And it is stale the
26
+ * moment the last proposal is enacted — it describes changes that are now
27
+ * approved — so approve deletes it, leaving a workspace `likec4 validate` accepts.
21
28
  */
22
29
  approve(): Promise<void>;
23
30
  /**
@@ -41,10 +48,12 @@ export declare class LikeC4Visualizer implements VisualizerPort {
41
48
  /**
42
49
  * Generate a focused diff view for every layer that holds a pending change.
43
50
  * Walks the diagram's own `#proposed` / `#proposal-delete` markers (never the
44
- * lock — a `#proposal-delete` has no lock delta), buckets each change into the
45
- * tightest scope that draws it, and writes one `view of <scope>` per bucket
46
- * to a derived `boundry.diff.likec4`. The file is overwritten each run and
47
- * removed when nothing is proposed, so it always reflects the current diagram.
51
+ * lock — a `#proposal-delete` has no lock delta) and writes one `view … of
52
+ * <scope>` per layer to a derived `boundry.diff.likec4`. A box is bucketed into
53
+ * its parent layer; an edge into *every* layer that draws it (see `edgeScopes`),
54
+ * so a cross-system dependency can be reviewed at each altitude, not only the
55
+ * common-ancestor layer. The file is overwritten each run and removed when
56
+ * nothing is proposed, so it always reflects the current diagram.
48
57
  */
49
58
  emitDiffViews(): Promise<DiffView[]>;
50
59
  }
@@ -28,6 +28,46 @@ function parentScope(fqn) {
28
28
  parts.pop();
29
29
  return parts.length ? parts.join('.') : undefined;
30
30
  }
31
+ /**
32
+ * Every layer that actually *draws* the edge `from -> to`, so a reviewer can open
33
+ * the change at whatever altitude matters — the whole system, a specific
34
+ * container, or the endpoint's immediate parent — not only the auto-picked common
35
+ * ancestor (issue #7). The set is derived from how LikeC4's `include *` renders a
36
+ * scoped view, verified against 1.58:
37
+ *
38
+ * - The tightest common ancestor draws it nested (both endpoints inside).
39
+ * - Every proper ancestor of an endpoint *below* that common ancestor draws it
40
+ * too: that side's subtree is expanded and the far endpoint is pulled in as a
41
+ * boundary node (e.g. `of s1` → `s1.c1 -> s2`).
42
+ * - Ancestors *above* the common ancestor are deliberately excluded: both
43
+ * endpoints collapse into the same child there, so the edge becomes a
44
+ * self-loop and LikeC4 draws nothing. The common ancestor is the widest layer
45
+ * that still draws it.
46
+ *
47
+ * So the scopes are the common ancestor plus each endpoint's ancestor chain from
48
+ * just below it down to the endpoint's parent — never the endpoints themselves
49
+ * (a view scoped *to* a leaf endpoint has an empty subtree).
50
+ */
51
+ function edgeScopes(from, to) {
52
+ const common = commonScope(from, to);
53
+ const depthOf = (scope) => (scope ? scope.split('.').length : 0);
54
+ const commonDepth = depthOf(common);
55
+ const byId = new Map();
56
+ const add = (scope) => {
57
+ byId.set(scopeViewId(scope), scope);
58
+ };
59
+ add(common);
60
+ for (const endpoint of [from, to]) {
61
+ const parts = endpoint.split('.');
62
+ // Proper ancestors of the endpoint strictly below the common ancestor: prefix
63
+ // lengths (commonDepth + 1) .. (parts.length - 1). The last is the endpoint's
64
+ // parent; the endpoint itself (full length) is never a scope.
65
+ for (let len = commonDepth + 1; len <= parts.length - 1; len++) {
66
+ add(parts.slice(0, len).join('.'));
67
+ }
68
+ }
69
+ return [...byId.values()];
70
+ }
31
71
  /**
32
72
  * Deterministic highlight rules for the marker tags actually in use. Referencing
33
73
  * an undeclared tag is a hard LikeC4 error, and a tag can only be *used* if it is
@@ -393,11 +433,20 @@ export class LikeC4Visualizer {
393
433
  * - `#proposal-delete` markers take their whole edge or box with them, so
394
434
  * approving a proposed removal actually removes it.
395
435
  * All other formatting is left intact.
436
+ *
437
+ * The derived `boundry.diff.likec4` is skipped, not approved: it holds no real
438
+ * markers, only view rules that *reference* the marker tags (`style element.tag
439
+ * = #proposed`, `include … where tag is #proposed`). Splicing those `#proposed`
440
+ * tokens out would corrupt the rules into invalid LikeC4. And it is stale the
441
+ * moment the last proposal is enacted — it describes changes that are now
442
+ * approved — so approve deletes it, leaving a workspace `likec4 validate` accepts.
396
443
  */
397
444
  async approve() {
398
445
  const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
399
446
  for (const doc of likec4.LangiumDocuments.all) {
400
447
  const filePath = doc.uri.fsPath;
448
+ if (filePath.endsWith(DIFF_FILE))
449
+ continue;
401
450
  const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
402
451
  const ranges = collectApprovalRanges(doc.parseResult?.value, text);
403
452
  if (ranges.length === 0)
@@ -408,6 +457,12 @@ export class LikeC4Visualizer {
408
457
  }
409
458
  writeFileSync(filePath, next);
410
459
  }
460
+ // Enacting the last proposal makes any diff views stale — they frame changes
461
+ // that are now approved. Remove the derived artifact so the post-approve
462
+ // workspace validates and never renders an approved-away change.
463
+ const diffPath = join(this.workspaceDir, DIFF_FILE);
464
+ if (existsSync(diffPath))
465
+ rmSync(diffPath);
411
466
  }
412
467
  /**
413
468
  * Deterministically mark the given edges and elements `#proposed`. The inverse
@@ -529,10 +584,12 @@ export class LikeC4Visualizer {
529
584
  /**
530
585
  * Generate a focused diff view for every layer that holds a pending change.
531
586
  * Walks the diagram's own `#proposed` / `#proposal-delete` markers (never the
532
- * lock — a `#proposal-delete` has no lock delta), buckets each change into the
533
- * tightest scope that draws it, and writes one `view of <scope>` per bucket
534
- * to a derived `boundry.diff.likec4`. The file is overwritten each run and
535
- * removed when nothing is proposed, so it always reflects the current diagram.
587
+ * lock — a `#proposal-delete` has no lock delta) and writes one `view … of
588
+ * <scope>` per layer to a derived `boundry.diff.likec4`. A box is bucketed into
589
+ * its parent layer; an edge into *every* layer that draws it (see `edgeScopes`),
590
+ * so a cross-system dependency can be reviewed at each altitude, not only the
591
+ * common-ancestor layer. The file is overwritten each run and removed when
592
+ * nothing is proposed, so it always reflects the current diagram.
536
593
  */
537
594
  async emitDiffViews() {
538
595
  const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
@@ -562,8 +619,11 @@ export class LikeC4Visualizer {
562
619
  if (statement?.$type === 'Relation') {
563
620
  const from = fqnOf(endpointElement(statement.source));
564
621
  const to = fqnOf(endpointElement(statement.target));
622
+ // One view per layer that actually draws this edge, so the reviewer can
623
+ // judge it at every altitude — not only the common-ancestor layer (#7).
565
624
  if (from && to)
566
- record(commonScope(from, to));
625
+ for (const scope of edgeScopes(from, to))
626
+ record(scope);
567
627
  }
568
628
  else if (statement?.$type === 'Element') {
569
629
  const fqn = fqnOf(statement);
package/dist/cli/index.js CHANGED
@@ -1,12 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { execFileSync } from 'node:child_process';
3
- import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
3
  import { join, relative, resolve } from 'node:path';
6
4
  import { Pipeline } from '../core/pipeline/pipeline.js';
7
5
  import { LikeC4Visualizer } from '../adapters/visualizer/likec4.js';
8
6
  import { DepCruiserEnforcer } from '../adapters/enforcer/depcruiser.js';
9
- const USAGE = 'usage: boundry <generate|check|approve|verify|annotate|diff> [--arch <dir>] [--base <git-ref>] [--cwd <dir>] [--out <file>] [sources...]';
7
+ const USAGE = 'usage: boundry <generate|check|approve|verify|annotate|diff> [--arch <dir>] [--cwd <dir>] [--out <file>] [sources...]';
10
8
  function optValue(args, flag) {
11
9
  const i = args.indexOf(flag);
12
10
  return i >= 0 ? args[i + 1] : undefined;
@@ -22,30 +20,6 @@ function positionals(args) {
22
20
  }
23
21
  return out;
24
22
  }
25
- /**
26
- * Materialize the architecture as of a git ref into a temp workspace, so the
27
- * previously-approved boundary model can be lifted and compared against HEAD.
28
- */
29
- function materializeArchAt(archDir, ref) {
30
- const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
31
- cwd: archDir,
32
- encoding: 'utf8',
33
- }).trim();
34
- const workDir = mkdtempSync(join(tmpdir(), 'boundry-base-'));
35
- for (const file of readdirSync(archDir).filter((f) => f.endsWith('.likec4'))) {
36
- try {
37
- const content = execFileSync('git', ['show', `${ref}:${relative(gitRoot, join(archDir, file))}`], {
38
- cwd: gitRoot,
39
- encoding: 'utf8',
40
- });
41
- writeFileSync(join(workDir, file), content);
42
- }
43
- catch {
44
- // The file did not exist at `ref` — everything it declares is new.
45
- }
46
- }
47
- return workDir;
48
- }
49
23
  function countGrants(granted) {
50
24
  return granted.edges.length + granted.exemptions.length;
51
25
  }
@@ -69,17 +43,17 @@ async function main() {
69
43
  const archDir = resolve(optValue(rest, '--arch') ?? '.');
70
44
  const outArg = optValue(rest, '--out');
71
45
  const outFile = outArg ? resolve(outArg) : undefined;
72
- const baseRef = optValue(rest, '--base');
73
46
  // `--cwd` lets you check a repo without cd-ing into it. `folder` metadata is
74
47
  // relative to the target repo root, so the enforcer runs from there.
75
48
  const cwd = optValue(rest, '--cwd');
76
49
  if (cwd)
77
50
  process.chdir(resolve(cwd));
78
51
  // The accepted-state lock lives beside the diagram it locks. `approve` writes
79
- // it; `annotate` reads it — the baseline Boundry owns, not one inferred from git.
52
+ // it; `verify` and `annotate` read it — the baseline Boundry owns, decoupled
53
+ // from git, so "accepted" never means merely "committed".
80
54
  const lockFile = join(archDir, 'boundry.lock');
55
+ const readLock = () => existsSync(lockFile) ? readFileSync(lockFile, 'utf8') : undefined;
81
56
  const pipeline = new Pipeline(new LikeC4Visualizer(archDir), new DepCruiserEnforcer());
82
- const grantedSince = (ref) => pipeline.verify(new LikeC4Visualizer(materializeArchAt(archDir, ref)));
83
57
  if (command === 'generate') {
84
58
  const config = await pipeline.generate();
85
59
  const out = outFile ?? resolve(config.filename);
@@ -105,37 +79,43 @@ async function main() {
105
79
  return;
106
80
  }
107
81
  if (command === 'verify') {
108
- if (!baseRef) {
109
- console.error('Boundry: verify requires --base <git-ref>');
82
+ const lock = readLock();
83
+ if (!lock) {
84
+ console.error(`Boundry: no ${relative(process.cwd(), lockFile)} — run 'boundry approve' first to record the accepted state`);
110
85
  process.exitCode = 2;
111
86
  return;
112
87
  }
113
- const granted = await grantedSince(baseRef);
88
+ const granted = await pipeline.verify(lock);
114
89
  if (countGrants(granted) === 0) {
115
- console.log(`Boundry: ✓ nothing granted without a #proposed marker (vs ${baseRef})`);
90
+ console.log('Boundry: ✓ nothing granted without a #proposed marker (vs the accepted lock)');
116
91
  return;
117
92
  }
118
- console.error(`Boundry: ✗ ${countGrants(granted)} grant(s) made without a #proposed marker (vs ${baseRef})`);
93
+ console.error(`Boundry: ✗ ${countGrants(granted)} grant(s) made without a #proposed marker (vs the accepted lock)`);
119
94
  listGrants(granted);
120
95
  process.exitCode = 1;
121
96
  return;
122
97
  }
123
98
  if (command === 'approve') {
124
99
  // Approving must never launder an edge that skipped the proposal protocol.
125
- if (baseRef) {
126
- const granted = await grantedSince(baseRef);
100
+ // The accepted lock is the baseline: a bare edge added since the last approve
101
+ // is in the allow-list but not the lock, so it surfaces here; a #proposed one
102
+ // is excluded from the allow-list, so it passes and gets enacted below. The
103
+ // first approve has no lock yet — it establishes the initial accepted state.
104
+ const lock = readLock();
105
+ if (lock) {
106
+ const granted = await pipeline.verify(lock);
127
107
  if (countGrants(granted) > 0) {
128
- console.error(`Boundry: ✗ refusing to approve — ${countGrants(granted)} grant(s) were made without a #proposed marker (vs ${baseRef})`);
108
+ console.error(`Boundry: ✗ refusing to approve — ${countGrants(granted)} grant(s) were made without a #proposed marker (vs the accepted lock)`);
129
109
  listGrants(granted);
130
110
  process.exitCode = 1;
131
111
  return;
132
112
  }
133
113
  }
134
114
  else {
135
- console.error('Boundry: ⚠ no --base given — approving without verifying that every new edge was proposed');
115
+ console.error('Boundry: ⚠ no boundry.lock yet — approving to record the initial accepted state');
136
116
  }
137
- const lock = await pipeline.approve();
138
- writeFileSync(lockFile, lock);
117
+ const nextLock = await pipeline.approve();
118
+ writeFileSync(lockFile, nextLock);
139
119
  console.log('Boundry: ✓ approved — enacted the diagram and wrote', relative(process.cwd(), lockFile));
140
120
  return;
141
121
  }
@@ -31,12 +31,18 @@ export declare class Pipeline {
31
31
  /** Diagram -> boundary model -> run the linter over `sources`. */
32
32
  check(sources: string[]): Promise<CheckResult>;
33
33
  /**
34
- * What this diagram grants that `base` did not — dependencies added without a
35
- * `#proposed` marker, and importer exemptions added. Proposals are excluded
36
- * from the allow-list, so anything reported here bypassed the approval
37
- * protocol.
34
+ * What this diagram grants that the accepted `lock` did not — dependencies
35
+ * added without a `#proposed` marker, and importer exemptions added. Proposals
36
+ * are excluded from the allow-list, so anything reported here bypassed the
37
+ * approval protocol.
38
+ *
39
+ * The baseline is the lock — the accepted state `approve` records — not a git
40
+ * ref. The lock exists precisely to decouple "accepted" from "committed", so
41
+ * verify reads from it, exactly as `annotate` does; the two can never disagree
42
+ * about what "accepted" means. It also catches a committed-but-unapproved bare
43
+ * edge, which re-deriving a baseline from a git ref would silently absorb.
38
44
  */
39
- verify(base: VisualizerPort): Promise<VerifyResult>;
45
+ verify(lock: string): Promise<VerifyResult>;
40
46
  /**
41
47
  * Enact the diagram's pending proposals (strip `#proposed`, remove
42
48
  * `#proposal-delete`), then return the resulting accepted model as a lock
@@ -21,16 +21,23 @@ export class Pipeline {
21
21
  return this.enforcer.check(model, sources);
22
22
  }
23
23
  /**
24
- * What this diagram grants that `base` did not — dependencies added without a
25
- * `#proposed` marker, and importer exemptions added. Proposals are excluded
26
- * from the allow-list, so anything reported here bypassed the approval
27
- * protocol.
24
+ * What this diagram grants that the accepted `lock` did not — dependencies
25
+ * added without a `#proposed` marker, and importer exemptions added. Proposals
26
+ * are excluded from the allow-list, so anything reported here bypassed the
27
+ * approval protocol.
28
+ *
29
+ * The baseline is the lock — the accepted state `approve` records — not a git
30
+ * ref. The lock exists precisely to decouple "accepted" from "committed", so
31
+ * verify reads from it, exactly as `annotate` does; the two can never disagree
32
+ * about what "accepted" means. It also catches a committed-but-unapproved bare
33
+ * edge, which re-deriving a baseline from a git ref would silently absorb.
28
34
  */
29
- async verify(base) {
30
- const [baseModel, headModel] = await Promise.all([base.read(), this.visualizer.read()]);
35
+ async verify(lock) {
36
+ const base = parseModel(lock);
37
+ const head = await this.visualizer.read();
31
38
  return {
32
- edges: newlyAllowedEdges(baseModel, headModel),
33
- exemptions: newlyExemptedImporters(baseModel, headModel),
39
+ edges: newlyAllowedEdges(base, head),
40
+ exemptions: newlyExemptedImporters(base, head),
34
41
  };
35
42
  }
36
43
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boundry",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Compile a C4 architecture diagram into a deterministic dependency linter. Deterministic architectural guardrails for AI agents and humans.",
5
5
  "type": "module",
6
6
  "license": "MIT",