@bongos/core 1.19.911 → 1.19.912

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/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.911",
6
- "core_contract": "1.19.911",
7
- "source_commit": "cabe904a411a1173750424491d289d428be9f56d",
5
+ "core_version": "1.19.912",
6
+ "core_contract": "1.19.912",
7
+ "source_commit": "d9093cd602f014a948cee8ac7ea27f36d4be922b",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-20T15:49:27.325Z",
9
+ "built_at": "2026-09-20T16:20:10.208Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 503,
12
+ "docs_redacted": 504,
13
13
  "agent_docs_stubbed": 26,
14
14
  "functional_verbatim": 2287,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2816,
20
- "tree_sha256": "c6780dcf9d3b31846621cbc9ad6fb681cd2ed710c21c2c308e99e612d4330d3f",
19
+ "file_count": 2817,
20
+ "tree_sha256": "e4f492bda78fcddcc35a433c3f5c7ca4b3e193298e923da342a7326e182fb695",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2062,12 +2062,17 @@
2062
2062
  {
2063
2063
  "path": "docs/adr/0314-module-protection-is-per-path-not-per-module.md",
2064
2064
  "mode": "0000644",
2065
- "sha256": "541a0ae0223982fe53fcf96e3c4a9509e94101d04db0a87395f39cc1bcd881e8"
2065
+ "sha256": "accaba461051e427c251e9c4ec4ff74304447fd6cd68f940311f4b978ff234c2"
2066
+ },
2067
+ {
2068
+ "path": "docs/adr/0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md",
2069
+ "mode": "0000644",
2070
+ "sha256": "bc9dde45a33790e4fbce47682a5734a1adaf4fdd7f9347bb0128c6cca6fcf4e5"
2066
2071
  },
2067
2072
  {
2068
2073
  "path": "docs/adr/README.md",
2069
2074
  "mode": "0000644",
2070
- "sha256": "4bd65e814cacf8b886b542bf7741dff8f61e9aad3e101db1af2bd28e0eb1a49e"
2075
+ "sha256": "a6bf1805ee02ca941a1910d722cfc61e4f0e519aa79c9d102aadfb8bf7d95a5a"
2071
2076
  },
2072
2077
  {
2073
2078
  "path": "docs/api-reference.md",
@@ -2637,7 +2642,7 @@
2637
2642
  {
2638
2643
  "path": "docs/module-api-changelog.md",
2639
2644
  "mode": "0000644",
2640
- "sha256": "801631cd9a51ad17337416dd08612a0a7963ba950f41b4660e8524284acdb5de"
2645
+ "sha256": "1dfb3933a3ce48499941b71978e9f462fe97c49e7d472b71022b654850093e3b"
2641
2646
  },
2642
2647
  {
2643
2648
  "path": "docs/modules-contract.md",
@@ -2857,7 +2862,7 @@
2857
2862
  {
2858
2863
  "path": "docs/recipes/self-host.md",
2859
2864
  "mode": "0000644",
2860
- "sha256": "853761ea1330806204c35bcdda680af64fac1f096be1a1296d9d59ed9d54fabd"
2865
+ "sha256": "2a8a4075086fbcc2815c7b809f38dd887726e53561755e65a7990a687772cf7c"
2861
2866
  },
2862
2867
  {
2863
2868
  "path": "docs/recipes/server-bring-up.md",
@@ -7792,12 +7797,12 @@
7792
7797
  {
7793
7798
  "path": "package-lock.json",
7794
7799
  "mode": "0000644",
7795
- "sha256": "d1e016c5b7678739cb8ea7f91f8d7260c60288775ce8c2817ae0d241573b793d"
7800
+ "sha256": "d424dadd5439bf9883d9e0e44a938ed30a2e5c63f4dd1ed336e6130dbef959e6"
7796
7801
  },
7797
7802
  {
7798
7803
  "path": "package.json",
7799
7804
  "mode": "0000644",
7800
- "sha256": "81148ca44a809ff5b54610f520e24171361dcf3bef297e7c0c8ae2e9b46a4bc6"
7805
+ "sha256": "ffdc1c047d136b67d5443336e8c8874b9cf7c13868a908a486470ffdcc780f3c"
7801
7806
  },
7802
7807
  {
7803
7808
  "path": "public-docs/index.html",
@@ -8752,7 +8757,7 @@
8752
8757
  {
8753
8758
  "path": "scripts/gds/provision-net.js",
8754
8759
  "mode": "0000644",
8755
- "sha256": "f1ca176ff8cdb8de65940af1e30316db600cb5df529abbc997697abf1cbc2c2d"
8760
+ "sha256": "734ae6982953f2474d424bf16789adfb709b5657ce62014df5c29a791506a988"
8756
8761
  },
8757
8762
  {
8758
8763
  "path": "scripts/gds/provision-process.js",
@@ -8782,7 +8787,7 @@
8782
8787
  {
8783
8788
  "path": "scripts/gds/provision.js",
8784
8789
  "mode": "0000644",
8785
- "sha256": "71d4edd0e5653207a535e6a0febca39a19ba281ffce7f248049b826dc5ab3b84"
8790
+ "sha256": "8722cf581fac65b5839f2b44987c9b09b721452d8f20d2079f2029b386841396"
8786
8791
  },
8787
8792
  {
8788
8793
  "path": "scripts/gds/publish-credential-check.js",
@@ -9607,7 +9612,7 @@
9607
9612
  {
9608
9613
  "path": "src/bongos/module-scope-map.js",
9609
9614
  "mode": "0000644",
9610
- "sha256": "7f393fc515fe751d5296ce0a574e0d3e40695c0346933a0d8b9c30ed6ae39a1b"
9615
+ "sha256": "b82d3257c27689e53b3d491251cc46d474c8f4da944e2e8f73242525df878484"
9611
9616
  },
9612
9617
  {
9613
9618
  "path": "src/bongos/module-submissions.js",
@@ -9797,7 +9802,7 @@
9797
9802
  {
9798
9803
  "path": "src/module-api.js",
9799
9804
  "mode": "0000644",
9800
- "sha256": "6d6e0bee05301dd054fa2f445d0a8a2ba456e016c0e3ad22cbe63aa9f9b455ae"
9805
+ "sha256": "b0f164fe792662f67fe4184880ea2197ae4b22bec2696fdcb1693778caf44999"
9801
9806
  },
9802
9807
  {
9803
9808
  "path": "src/module-loader/catalog.js",
@@ -12297,7 +12302,7 @@
12297
12302
  {
12298
12303
  "path": "tests/module-scope-map.mjs",
12299
12304
  "mode": "0000644",
12300
- "sha256": "b2609440d099b93b61c63c3cdaa4cf6e65c6dc107b8aa4d06d9cb81c9ec5e2c9"
12305
+ "sha256": "fae747eaeea8e9d657de097a72e5f8d244bd2ece6c7e890ff4c9756787721bc5"
12301
12306
  },
12302
12307
  {
12303
12308
  "path": "tests/module_api.mjs",
@@ -12782,7 +12787,7 @@
12782
12787
  {
12783
12788
  "path": "tests/provision_caddy_wiring.mjs",
12784
12789
  "mode": "0000644",
12785
- "sha256": "a4d448622c05e963602cbc0488215ba168c909bd12cd0db20eaa6fcb7de7a91e"
12790
+ "sha256": "2eb850e431c5355d2645eec48554c686ad5b57222ee863489a29b78d945b7a26"
12786
12791
  },
12787
12792
  {
12788
12793
  "path": "tests/provision_disconnect.mjs",
@@ -12967,7 +12972,7 @@
12967
12972
  {
12968
12973
  "path": "tests/quarantine_canonical.mjs",
12969
12974
  "mode": "0000644",
12970
- "sha256": "d8a2376c5ff96d77378626c9b4d1c32660e8412afb2531cf93085564890ffdef"
12975
+ "sha256": "672096eb5917d45b1c77e1d07c4b7a6613a0714eb4b38327057fcea11a41b7ad"
12971
12976
  },
12972
12977
  {
12973
12978
  "path": "tests/rank_floor_wiring.mjs",
@@ -1,6 +1,6 @@
1
1
  # ADR 0314 — A module owns a protected path without the whole module becoming protected
2
2
 
3
- - **Status:** accepted (design only — the implementation is a follow-up, see *Not built here*)
3
+ - **Status:** accepted — **implemented** by [task 1004109](https://cloudbongos.com/builders#/task/1004109) (2026-09-20)
4
4
  - **Date:** 2026-09-20
5
5
  - **Task:** [task 1004070](https://cloudbongos.com/builders#/task/1004070), goal 1000095 — BONGOS-V2
6
6
  - **From:** idea 1000689; owner decision 2026-09-19 — *option 1, narrow what counts as protected*
@@ -105,7 +105,12 @@ Three reasons this is the honest shape rather than the convenient one:
105
105
  Per the task: an ADR first, implementation as a follow-up. This decision builds no
106
106
  new derivation mechanism. Filed separately:
107
107
 
108
- - The implementation of the per-path goal-scope wall.
108
+ - ~~The implementation of the per-path goal-scope wall.~~ **Landed** in
109
+ [task 1004109](https://cloudbongos.com/builders#/task/1004109): `isProtected` now
110
+ filters a module's protecting globs to those that are not CLI entrypoints (a
111
+ structural path test — directories under `scripts/` are territory and still
112
+ protect), narrowing the derived set from twelve to the nine predicted below.
113
+ Nothing was unprotected to achieve it and the path wall did not move.
109
114
  - **`builder-settings`, deliberately out of scope.** Its overlap is
110
115
  `src/bongos/routes/me.js` caught by the blanket `src/bongos/routes/` prefix, so
111
116
  narrowing it means either finishing the strangler move of `me.js` into
@@ -0,0 +1,96 @@
1
+ # ADR 0315 — A plane with no proxy is refused, not given a no-op ingress
2
+
3
+ **Status:** accepted · **Date:** 2026-09-20 · **Task:** [task 1004017](https://cloudbongos.com/builders#/task/1004017) (goal 1000106 — BONGOS-V2)
4
+
5
+ **Related:** [ADR 0303](0303-provisioning-verifies-the-base-caddyfile-and-never-edits-it.md) (detect-and-refuse on the base Caddyfile — the check this one sits in front of) · [ADR 0306](0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md) (the supervision seam that explicitly deferred Caddy reload to this task) · [ADR 0285](0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md) (the capacity preflight this one is ordered behind) · [ADR 0111](0111-instance-hosting-provisioning-module.md) (the standup runbook both checks live inside)
6
+
7
+ ## Context
8
+
9
+ Provisioning step 6 writes a per-instance `<slug>.caddy` snippet and runs
10
+ `systemctl reload caddy`. The self-host image installs `postgresql-client bash tini curl
11
+ ca-certificates git`, runs `USER node`, and ships no proxy of any kind — so on a compose
12
+ self-host that step cannot work, and a child instance provisioned there has no front door.
13
+
14
+ ADR 0306 named this explicitly as the piece its seam does not cover: *"`systemctl reload
15
+ caddy` is shared-proxy config, governed by ADR 0303, and the container has no Caddy at
16
+ all — that is task 1004017, not this seam."*
17
+
18
+ **Two failure shapes, and only one of them was visible.** ADR 0303's preflight is gated on
19
+ `inst.domain`, so:
20
+
21
+ - A **domained** child already half-refused — `caddy adapt` fails with no binary, which
22
+ ADR 0303's own `catch` turns into a gap string. But the message told the operator to add
23
+ a directive to `/etc/caddy/Caddyfile`: a file the container does not have and that
24
+ provisioning will never create (ADR 0303 §1 forbids writing it).
25
+ - A **domainless** child skipped the preflight entirely, walked to step 6, and died on
26
+ `boxWriteFile('/etc/caddy/sites/<slug>.caddy', …, {sudo: true})`. That throw is uncaught,
27
+ so it bypasses the `{ok:false}` pending-retry path and hard-errors the instance on attempt
28
+ one. The runner recorded a crash where the honest answer was a refusal.
29
+
30
+ ## Decision
31
+
32
+ **A plane with no reverse proxy is refused before anything is attempted, and the refusal
33
+ names what the plane lacks.**
34
+
35
+ `ingressPlaneGap(exec)` in `scripts/gds/provision-net.js` probes the plane with
36
+ `caddy version` — one cheap, unprivileged, read-only command, the same posture as
37
+ `checkCaddyWiring`'s `caddy adapt`. It returns `null` when a proxy answers, or a string
38
+ naming the missing proxy. Both standup legs (`provisionInstance` step 6's owner, and
39
+ `domainAttachInstance`) call it and return `{ok:false, error: 'Ingress preflight failed: …'}`.
40
+
41
+ **It is deliberately NOT domain-gated.** That is the whole point: the domainless child is
42
+ the one ADR 0303 could not see.
43
+
44
+ **A missing binary is the only failure that proves there is no proxy.** Under remote exec
45
+ `boxExec` is `ssh <host> bash -s`, so a briefly-unreachable co-hosting box throws here too —
46
+ and answering that with "this plane has no reverse proxy, provision from a host that runs
47
+ Caddy" would be a confident wrong diagnosis, burning the intent's retry budget on a network
48
+ blip. Exit 127 (and the `command not found` / `is not recognized` wordings) is read as proof;
49
+ anything else returns *"could not confirm a reverse proxy on this plane"* with the real cause
50
+ leading. ssh reports its own failures as 255 and otherwise propagates the remote command's
51
+ status, so 127 still means "no caddy" across the hop.
52
+
53
+ **The three refusals are ordered capacity → ingress → wiring**, cheapest and most
54
+ fundamental first, and all three return before anything on the box changes:
55
+
56
+ 1. `capacityPreflight` measures locally (ADR 0285 promises a full box is measured first and
57
+ refuses before anything else runs);
58
+ 2. ingress asks whether this plane has a proxy **at all**;
59
+ 3. ADR 0303 then asks whether that proxy is wired for **this domain**.
60
+
61
+ Asking 0303 first is what produced the "edit a Caddyfile you do not have" answer, so this
62
+ ADR moves 0303's preflight behind the capacity measurement to make room. Both still refuse
63
+ before any mutation, so neither guarantee is weakened — only the precedence between two
64
+ refusals changes.
65
+
66
+ **No ingress seam, and no no-op backend.** The obvious symmetry with ADR 0306 would be a
67
+ `resolveIngress()` registry with a `none` implementation whose `writeSite`/`reload` are
68
+ no-ops. That is rejected: a no-op backend lets the standup **succeed** while leaving the
69
+ child unreachable, which is neither "reachable at a documented address" nor "refuses with a
70
+ clear error" — it is the silently-broken instance this task exists to prevent. ADR 0306's
71
+ seam exists because two real planes both need supervising; here the second plane cannot
72
+ serve a child at all, and the honest encoding of that is a refusal, not an implementation.
73
+
74
+ ## Consequences
75
+
76
+ - **A compose self-host still cannot provision a child instance — it now says so.** This
77
+ closes the failure mode, not the capability. Giving the container a real front door
78
+ (a published port, a bundled proxy, an explicit localhost-only shape) remains unbuilt, and
79
+ a future ADR that adds one replaces this refusal rather than extending it.
80
+
81
+ - **One extra `caddy version` per applied standup.** Read-only, unprivileged, and it runs
82
+ after the capacity measurement, so a full box still refuses without it. A dry run never
83
+ probes, and a dedicated droplet is skipped — Caddy comes from cloud-init there.
84
+
85
+ - **ADR 0303's territory is untouched.** "Caddy is here but mis-wired" is still its question
86
+ and still its message; this ADR only answers the prior one. Every command string ADR 0303
87
+ pins in `tests/provision_caddy_wiring.mjs`, `tests/provision.mjs` and
88
+ `tests/provision_teardown_address.mjs` is unchanged.
89
+
90
+ - **Teardown is not covered.** `provision-teardown.js` removes the snippet and reloads with
91
+ `allowFail: true`, so it already degrades rather than crashing. Left alone deliberately: a
92
+ refusal on the teardown path would strand a row instead of cleaning it.
93
+
94
+ - **The probe is a capability question, not a configuration knob.** There is no
95
+ `PROVISION_INGRESS` env var. A plane either answers `caddy version` or it does not, and an
96
+ operator who wants provisioning to proceed needs a proxy, not a flag that says to pretend.
@@ -406,3 +406,4 @@ This keeps the decision history honest and traceable.
406
406
  | 0312 | [**Provenance is a SECOND ideas-owned edge table, written by the seams that already know** ([task 1004080](https://cloudbongos.com/builders#/task/1004080), goal 1000095 — BONGOS-V2). The spike [ADR 0311](0311-an-idea-is-a-permanent-origin-tag.md) deferred: how the descent edges are physically recorded. A descent (`idea → task → rewrite → split → removal`) is four levels deep and crosses a version boundary, and the task hierarchy refuses both — `attachParent` enforces `SELF_PARENT`, `VERSION_MISMATCH`, `PARENT_HAS_PARENT` and `TARGET_HAS_CHILDREN`, all load-bearing for ship rollup and claim safety. The spike was told to CONFIRM `ideas_task_lineage` rather than assume it, and confirmed one half while refuting the other: the table is right as the **idea→task anchor** (bipartite, no depth cap, no version predicate) but CANNOT carry the task→task edge, because a derivation is a property of the task PAIR and [ADR 0236](0236-many-ideas-one-task-the-lineage-key-is-the-pair.md) made co-source ideas both legal and common — a `derived_from_task_id` column would write the same derivation once per co-source idea and let two rows disagree about where a task came from, the same under-keying that silently lost attribution on prod once already. A merge also gives a task SEVERAL parents, so the relation is a **DAG, not a tree**. Decision: a new `ideas_task_descent` (`task_id`, `parent_task_id`, CHECK'd `relation`, PK on the pair, indexed both ways), with NO depth cap and NO version predicate, and **cycles refused at write time** because the payout walks this graph and a cycle is a non-terminating walk rather than a cosmetic error. On who writes it — the question the task actually set, since an edge depending on memory will have holes — **three seams already know and none is telling anyone**: `cascade.js` encodes the originating task id inside `source_ref` as a STRING nothing can join on, `tasks.merged_into_task_id` already records the fold, and `parent_task_id` already records the split. Only `rewrite` and cross-version `merge` stay manual, which is honest — they are editorial acts with no automatic signal, and their absence degrades the picture rather than corrupting it. What it fixes: `sourceIdeaForTask` and `awardIdeaTaskShipCredit` are both one-hop `LIMIT 1` point reads, so a DERIVED task resolves to `no_source_idea` and the origin is not paid at all. Does not decide: the decay rate beyond its default, what counts as removed ([ADR 0313](0313-a-removal-is-proposed-by-the-ship-and-confirmed-by-a-person.md)), or how any of it is drawn.](0312-provenance-is-a-second-ideas-owned-edge-table.md) | ideas / lifecycle |
407
407
  | 0313 | [**A removal is derived from the SERVER's own merge diff and confirmed by a person** ([task 1004081](https://cloudbongos.com/builders#/task/1004081), goal 1000095 — BONGOS-V2). The other half [ADR 0311](0311-an-idea-is-a-permanent-origin-tag.md) deferred: what counts as removed, so that brightness can fall at all. Nothing records it today — `shipped` is terminal (`rot.js` says so outright) and `merged_into_task_id` is CHECK'd to `abandoned` rows, describing only work cut BEFORE it landed. Owner decision 13 set the shape (system proposes, person confirms); this settles the detail, and corrects two premises the task was written on. **`cascade.js` is no longer empty** — it carries a production rule and [ADR 0242](0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md) fixed the contract (append an entry, never touch the runner), so there is a precedent to copy including its anti-recursion guard. **`netNegativeBonusAmount` cannot be the trigger** despite being the named nearest signal: it is an aggregate with NO TARGET (it says deletion happened, never what or whose), it is CLIENT-SUPPLIED via `signals.diff_stats`, and `grader-score.js` already documents the forgery it defends against — and brightness sits beside a payout, so a forgeable target-less counter cannot carry it. **Nothing durable records what any ship touched**: `committed_files` and `diff_stats` are client-supplied at grade time and never persisted, `touches[]` is advisory ([ADR 0049](0049-split-parallel-safety-contract.md)), so a removal cannot be reconstructed from history — only derived as a ship lands. Decision: derive it from the SERVER's own merge diff ([ADR 0055](0055-server-mediated-branch-publish.md) — the server holds the repo and does the merge, so it computes the diff rather than being told one), and attribute the deleted hunks through **`claims.published_head_sha`** (`migrations/171`), NOT by parsing `(task NNNN)` out of commit text — only 7 of `main`'s last 15 commits carry a task id, and a convention that holds most of the time cannot underwrite a payout. An UNATTRIBUTABLE deletion files nothing: a wrong proposal costs attention and dims a real star. A rename, a move and a rewrite-in-place are NOT removals, and a cold body is not a remnant — removal applies only below a task that actually shipped. The proposal is an appended `CASCADES` entry filing a QUESTION; confirmation is Metic+/`task.promote` on the existing awaiting-nod surface; an unanswered one rots on the normal clock. Only a CONFIRMED proposal dims a star. The derivation must be NON-BLOCKING — landing work matters more than dimming a star on time. Brightness can only fall forward from here, so historical sizes need a marker or the number lies.](0313-a-removal-is-proposed-by-the-ship-and-confirmed-by-a-person.md) | ideas / lifecycle |
408
408
  | 0314 | [**A module owns a protected path without the whole module becoming protected** ([task 1004070](https://cloudbongos.com/builders#/task/1004070), goal 1000095 — BONGOS-V2). Three modules derive `protected` — and so are Archon-only to scope a goal to — because of ONE file that is not their domain: `onboarding` on `scripts/gds/setup.js`, `autonomy` on `scripts/gds/conductor-dispatch.js`, `copy-desk` on `scripts/gds/copy-inventory.js`. The owner chose *narrow what counts as protected*; the task set the fork and required the choice be justified before any glob moved. **Deleting the glob is out**: `MODULE_GLOBS` is also the territory deciding who may AUTHOR a task on a path, and the shared floor is only `tests/`, so an unowned path is authorable by no goal at all. That left reassigning each CLI to an already-protected module — **measured, not reasoned about**, by applying the edit to the source and re-requiring in a fresh process (`MODULE_SCOPE_MAP` is frozen and built from a COPY of `MODULE_GLOBS` at load, so an in-process mutation proves nothing and passes green). The measurement kills it: `copy-desk` and `autonomy` lose the ability to author on their own generator and their own conductor, and — the row that decides it — `devsecops` as the new owner **cannot author the generator together with the two artifacts it writes**, because `docs/copy-registry.json` and `docs/copy-inventory.md` stay `copy-desk`'s. So *change a UI string, regenerate the registry* becomes authorable by no single goal, and that is the ship-check freshness gate every UI task hits. Decision: **protection becomes per-path at the goal-scope wall, as it already is at the other three** — `matchProtected` decides task-authoring, the grader and gate decide ship, the post-push audit decides the dishonest case; only goal-creation asked about the MODULE, and that inconsistency is the whole defect. The derivation stays computed from `PROTECTED_GLOBS` and no flag is hand-set ([ADR 0086](0086-goal-scoped-work-hierarchy.md) §7 / SEC #863), and the Metic floor on those paths does not move. **Corrects the task's own numbers**: 12 of 26 modules derive protected, not 11, and `agents` (protected by `modules/agents/`, itself a protected glob) is absent from its list of seven — which is why the one thing this ADR SHIPS is a test pinning the full protected key set **by name**, a count assertion passing on the wrong twelve being exactly how that drifted. Implementation is deferred to task 1004109, per the task's own instruction to stop after the ADR. Also filed: `builder-settings` (task 1004110), whose overlap rides the blanket `src/bongos/routes/` prefix and so needs either the `me.js` strangler move or its own security ADR; and **nine protected paths owned by no module at all** (task 1004111) — the freshness generators, the merge-driver pair and `cli-lib.js` among them — found while checking whether deletion was viable, a pre-existing hole rather than one this opens. Does not decide: the floor a protected path carries, whether `scripts/gds/` deserves a shared-floor entry the way `tests/` has one, or how the nine unowned paths get a home.](0314-module-protection-is-per-path-not-per-module.md) | modules / permissions |
409
+ | 0315 | [**A plane with no proxy is refused, not given a no-op ingress** ([task 1004017](https://cloudbongos.com/builders#/task/1004017), goal 1000106 — BONGOS-V2). The piece [ADR 0306](0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md) explicitly deferred: the self-host image ships no proxy, so provisioning step 6's `<slug>.caddy` snippet write and `systemctl reload caddy` cannot work there and a child instance gets no front door. **Two failure shapes, only one visible**: a DOMAINED child already half-refused, because `caddy adapt` fails with no binary and [ADR 0303](0303-provisioning-verifies-the-base-caddyfile-and-never-edits-it.md)'s own catch turns that into a gap — but the message told the operator to add a directive to `/etc/caddy/Caddyfile`, a file the container does not have and that ADR 0303 §1 forbids provisioning from creating; a DOMAINLESS child skipped that preflight entirely (it is gated on `inst.domain`), reached step 6 and died on the `sudo` write, an uncaught throw that bypasses the `{ok:false}` pending-retry path and hard-errors the instance on attempt one — a crash recorded where a refusal was the honest answer. Decision: `ingressPlaneGap(exec)` probes the plane with `caddy version` — one cheap unprivileged read, the same posture as `checkCaddyWiring` — and both standup legs refuse with `Ingress preflight failed: …`, deliberately NOT domain-gated, because the domainless child is the one 0303 could not see. **Only a missing binary proves there is no proxy**: under remote exec `boxExec` is `ssh <host> bash -s`, so an unreachable co-hosting box throws here too, and diagnosing a network blip as 'this plane has no proxy, provision elsewhere' would be a confident wrong answer that burns the intent's retry budget — exit 127 is read as proof, anything else answers 'could not confirm a reverse proxy on this plane' with the real cause leading. The three refusals are ordered **capacity → ingress → wiring**, cheapest and most fundamental first and all before any mutation, which moves 0303's preflight behind the ADR 0285 capacity measurement; both still refuse before anything changes, so only the precedence between two refusals moves. **No ingress seam and no no-op backend**, despite the obvious symmetry with 0306: a `none` implementation whose `writeSite`/`reload` are no-ops lets the standup SUCCEED while leaving the child unreachable — neither half of the task's done-when, and exactly the silently-broken instance it exists to prevent. Does not decide: what a child's front door IS on a container plane (a published port, a bundled proxy, an explicit localhost-only shape) — a future ADR that adds one replaces this refusal rather than extending it. Teardown is left alone on purpose: it already reloads `allowFail`, and refusing there would strand a row instead of cleaning it.](0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md) | provisioning / ingress |
@@ -2296,5 +2296,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2296
2296
  landed since 1.19.909 with no explicit bump. run 35519966415. (task 1002620)
2297
2297
  1.19.911 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2298
2298
  landed since 1.19.910 with no explicit bump. run 35520751450. (task 1002620)
2299
+ 1.19.912 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2300
+ landed since 1.19.911 with no explicit bump. run 35522352719. (task 1002620)
2299
2301
  ---------------------------------------------------------------------------
2300
2302
  ```
@@ -39,7 +39,7 @@ Author `config/branding.json` from the neutral starter — see [the branding con
39
39
 
40
40
  **Or scaffold it with `bongos init`** ([task #1204](https://example.com/builders#/task/1204)): a short interview (or `bongos init --from spec.json` for a hands-free / CI run) that writes both config files, **seeds your first version + its done-when goal**, and **files the human-only kickoff steps** (register a GitHub OAuth app, first owner sign-in, repo binding, admission) as real tracked tasks — so a fresh instance has a claimable queue on day one instead of an empty hall. It never invents your branding (it points you at the fill prompt for that). Preview the whole plan with `bongos init --dry-run`.
41
41
 
42
- **Production hosting, optional (the `provisioning` module — [ADR 0111](../adr/0111-instance-hosting-provisioning-module.md) §3).** If you enable the `provisioning` module and answer the interview's hosting questions (shape — co-tenant `$0` or a dedicated droplet — bring-your-own domain, credential ref, tier), init turns those answers into a real **provisioning request** (a queued `provision` intent the control-plane runner drains) instead of only filing a manual "production topology" task. **A queued request does nothing until a runner drains it:** on the control plane that is a systemd timer running `node scripts/gds/provision.js run-intents --apply`. This Docker image now carries the tools the scaffold needs — git, and a core it can package out of its own `.git`-less checkout (task 1003995) — and supervision is no longer systemd-only: the runner picks a process manager, falling back to supervisord where systemd is not running (task 1003997, [ADR 0306](../adr/0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md)). Three standup legs still assume a systemd box, so on a compose self-host a new project waits indefinitely: the container has no reverse proxy for a child instance's front door (task 1004017), cannot create the per-instance unix account that isolates one instance from another (task 1004018), and installs its backup dir as a hardcoded founder user (task 1002719). Note supervisord has no timer concept either, so a container-hosted instance gets no automatic DB backup — the standup log prints the `crontab` line to add instead. The two irreducibly-human legs — the GitHub OAuth app and the first owner sign-in — stay guided/manual. If the module isn't enabled on the target instance, init falls back to the manual production-topology task, so nothing is silently skipped.
42
+ **Production hosting, optional (the `provisioning` module — [ADR 0111](../adr/0111-instance-hosting-provisioning-module.md) §3).** If you enable the `provisioning` module and answer the interview's hosting questions (shape — co-tenant `$0` or a dedicated droplet — bring-your-own domain, credential ref, tier), init turns those answers into a real **provisioning request** (a queued `provision` intent the control-plane runner drains) instead of only filing a manual "production topology" task. **A queued request does nothing until a runner drains it:** on the control plane that is a systemd timer running `node scripts/gds/provision.js run-intents --apply`. This Docker image now carries the tools the scaffold needs — git, and a core it can package out of its own `.git`-less checkout (task 1003995) — and supervision is no longer systemd-only: the runner picks a process manager, falling back to supervisord where systemd is not running (task 1003997, [ADR 0306](../adr/0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md)). Three standup legs still assume a systemd box, so on a compose self-host a new project cannot stand up: the container has no reverse proxy for a child instance's front door, cannot create the per-instance unix account that isolates one instance from another (task 1004018), and installs its backup dir as a hardcoded founder user (task 1002719). **The ingress leg now refuses instead of failing opaquely** (task 1004017, [ADR 0315](../adr/0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md)): provisioning probes the plane with `caddy version` and returns `Ingress preflight failed: …` naming the missing proxy, before it writes a Caddy snippet or reloads anything. Previously a domained child was told to edit an `/etc/caddy/Caddyfile` the container does not have, and a domainless one crashed on the snippet write. Giving the container a real front door is still unbuilt — the refusal closes the failure mode, not the capability. Note supervisord has no timer concept either, so a container-hosted instance gets no automatic DB backup — the standup log prints the `crontab` line to add instead. The two irreducibly-human legs — the GitHub OAuth app and the first owner sign-in — stay guided/manual. If the module isn't enabled on the target instance, init falls back to the manual production-topology task, so nothing is silently skipped.
43
43
 
44
44
  ## Configuration surface
45
45
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.911",
3
+ "version": "1.19.912",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.911",
9
+ "version": "1.19.912",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.911",
3
+ "version": "1.19.912",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -270,6 +270,45 @@ function checkCaddyWiring(exec, opts = {}) {
270
270
  }
271
271
  return caddyWiringGap(res && res.stdout, { ...opts, caddyfile });
272
272
  }
273
+ // Can this plane give a child instance a front door AT ALL? (task 1004017)
274
+ //
275
+ // ADR 0303 asks whether a box's Caddy is wired right. This is the question BEFORE it: the
276
+ // self-host image installs no caddy binary and runs unprivileged, so step 6's snippet write
277
+ // and `systemctl reload caddy` cannot work there. The 0303 preflight is gated on inst.domain,
278
+ // so a DOMAINLESS child skipped it entirely and died on the write — a hard error on attempt
279
+ // one, not the pending-retry a refusal earns. Probed, not configured: a plane either answers
280
+ // `caddy version` or it does not. Same cheap-read posture as checkCaddyWiring — no privilege,
281
+ // no config, no running server.
282
+ // A MISSING BINARY is the only failure that proves there is no proxy. Under remote exec
283
+ // boxExec is `ssh <host> bash -s`, which throws on a transport blip too — and diagnosing a
284
+ // briefly-unreachable co-hosting box as "this platform has no proxy, provision elsewhere"
285
+ // would be a confident wrong answer, the thing this check exists to stop. ssh reports its
286
+ // OWN failures as 255 and otherwise propagates the remote command's status, so 127 still
287
+ // means "no caddy" across the hop. cmd.exe says 9009 and words it differently.
288
+ const NO_CADDY_BINARY = /command not found|not recognized as an internal|No such file or directory/i;
289
+ function ingressPlaneGap(exec, { caddyfile = CONFIG.caddyfile } = {}) {
290
+ try { exec('caddy version'); return null; } catch (e) {
291
+ const why = String((e && (e.stderr || e.message)) || e).trim().slice(0, 200);
292
+ const status = e && e.status;
293
+ if (status !== 127 && status !== 9009 && !NO_CADDY_BINARY.test(why)) {
294
+ return `could not confirm a reverse proxy on this plane: 'caddy version' failed (${why}). Provisioning writes a Caddy site snippet for every child instance, so it refuses rather than assume — re-run once the box answers.`;
295
+ }
296
+ return `this plane has no reverse proxy, so a child instance would have no front door: 'caddy version' failed (${why}). Provision from a host that runs Caddy — a plane with no proxy cannot serve a child, and ${caddyfile} is not something provisioning will create.`;
297
+ }
298
+ }
299
+ // The preflight around ingressPlaneGap: the narration its sibling preflights already carry,
300
+ // including the dry-run line without which the plan prints step 6's Caddy write as though it
301
+ // will work. Returns the refusal, or null to proceed. `dedicated` skips it (Caddy comes from
302
+ // cloud-init). Lives here rather than inline at the two call sites to keep provision.js under
303
+ // its 1,500-line ratchet.
304
+ function ingressPreflight({ exec, apply, log, shape, probe = ingressPlaneGap, before = 'writing any Caddy config' }) {
305
+ if (shape === 'dedicated') return null;
306
+ if (!apply) { log(` [preflight] would probe the plane for a reverse proxy before ${before}`); return null; }
307
+ const missing = probe(exec);
308
+ if (missing) return `Ingress preflight failed: ${missing}`;
309
+ log(' [preflight] the plane serves a reverse proxy ✓');
310
+ return null;
311
+ }
273
312
  function healthzCmd(inst) {
274
313
  return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/healthz`;
275
314
  }
@@ -322,6 +361,8 @@ module.exports = {
322
361
  healthzCmd,
323
362
  identityCmd,
324
363
  identityVerdict,
364
+ ingressPlaneGap,
365
+ ingressPreflight,
325
366
  shouldFederate,
326
367
  zoneForDomain,
327
368
  };
@@ -52,7 +52,7 @@ const crypto = require('node:crypto');
52
52
  const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
53
53
  const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
54
54
  const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
55
- const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
55
+ const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, ingressPlaneGap, ingressPreflight, shouldFederate, zoneForDomain } = require('./provision-net.js');
56
56
  const { resolveProcessManager } = require('./provision-process.js');
57
57
  const { capacityPreflight, statusCapacityLine } = require('./provision-capacity.js');
58
58
  const { installedCoreVersion, runnerBanner } = require('./runner-drift.js');
@@ -268,6 +268,14 @@ async function provisionInstance(inst, deps) {
268
268
  log(` [preflight] would verify the Cloudflare token can reach zone ${zoneForDomain(inst.domain)} before the address step`);
269
269
  }
270
270
  }
271
+ const room = await capacityPreflight(inst, deps); // box capacity (ADR 0285, task 1003927): an honest refusal BEFORE the status flip — never a silent oversubscribe
272
+ if (room.ok === false) return room;
273
+ // Ingress preflight (task 1004017, ADR 0315). Ordered capacity → ingress → wiring, all three
274
+ // before any mutation: ingress asks whether this plane has a proxy AT ALL, 0303 below whether
275
+ // that proxy is wired for THIS domain. Deliberately NOT domain-gated — a domainless child
276
+ // skipped 0303 entirely, reached step 6 and died on the snippet write.
277
+ const ingressGap = ingressPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, probe: deps.ingressPlaneGap });
278
+ if (ingressGap) return { ok: false, error: ingressGap };
271
279
  // Caddy preflight (task 1002860, ADR 0303): a box whose base Caddyfile has no tls-check ask
272
280
  // can never certify this domain, so refuse before touching it. Injectable like the DNS check.
273
281
  const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring;
@@ -275,8 +283,6 @@ async function provisionInstance(inst, deps) {
275
283
  const gap = caddyCheck(boxExec, { domain: inst.domain });
276
284
  if (gap) return { ok: false, error: `Caddy preflight failed: ${gap}` };
277
285
  }
278
- const room = await capacityPreflight(inst, deps); // box capacity (ADR 0285, task 1003927): an honest refusal BEFORE the status flip — never a silent oversubscribe
279
- if (room.ok === false) return room;
280
286
  if (apply) await provisioning.setInstanceStatus(db, inst.id, 'provisioning');
281
287
  const patch = {};
282
288
 
@@ -750,6 +756,8 @@ async function domainAttachInstance(inst, deps) {
750
756
  }
751
757
  log(` [preflight] DNS token can reach zone ${scope.zone} ✓`);
752
758
  }
759
+ const ingressGap = ingressPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, probe: deps.ingressPlaneGap, before: 're-writing the Caddy snippet' });
760
+ if (ingressGap) return { ok: false, error: ingressGap }; // Ingress preflight (task 1004017, ADR 0315)
753
761
  const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring; // Caddy preflight (task 1002860, ADR 0303)
754
762
  if (apply) {
755
763
  const gap = caddyCheck(boxExec, { domain: inst.domain });
@@ -456,12 +456,50 @@ const SHARED_GLOBS = Object.freeze(
456
456
  SHARED_GLOBS_RAW.filter((g) => !touchesOverlap([g], PROTECTED_GLOBS)),
457
457
  );
458
458
 
459
- // A glob set is protected iff it overlaps the imported PROTECTED_GLOBS — the one
460
- // source of truth (permission-path-check.js). Prefix-aware both directions
461
- // (path-match.touchesOverlap), so e.g. a module owning 'src/bongos/routes/inbox.js'
462
- // is protected because PROTECTED_GLOBS carries the 'src/bongos/routes/' subtree.
459
+ // A CLI ENTRYPOINT — a single executable script under scripts/, e.g.
460
+ // 'scripts/gds/setup.js'. Structural, not a list: it is a path test, so no roster
461
+ // is hand-maintained here and nothing has to be kept in step with PROTECTED_GLOBS.
462
+ // Directories ('scripts/deploy/', 'scripts/gds/hooks/') are territory, not
463
+ // commands, and are deliberately NOT matched.
464
+ function isCommandPath(glob) {
465
+ return /^scripts\/.*\.js$/.test(glob);
466
+ }
467
+
468
+ // Which of a module's globs make it protected? A glob qualifies iff it overlaps
469
+ // the imported PROTECTED_GLOBS — the one source of truth (permission-path-check.js),
470
+ // prefix-aware both directions (path-match.touchesOverlap), so e.g. a module owning
471
+ // 'src/bongos/routes/inbox.js' qualifies because PROTECTED_GLOBS carries the
472
+ // 'src/bongos/routes/' subtree.
473
+ function protectingGlobs(globs) {
474
+ return (globs || []).filter((g) => touchesOverlap([g], PROTECTED_GLOBS));
475
+ }
476
+
477
+ // A module is protected iff it owns protected TERRITORY — ADR 0314.
478
+ //
479
+ // WHY THIS IS NOT JUST touchesOverlap(globs, PROTECTED_GLOBS) ANY MORE.
480
+ // That asked "does this module touch the trust core", and three modules answered
481
+ // yes because of exactly one CLI they own that happens to sit on a protected
482
+ // path: onboarding (scripts/gds/setup.js), autonomy (conductor-dispatch.js),
483
+ // copy-desk (copy-inventory.js). Owning the command is not owning the authority
484
+ // — modules/government/ IS government, but setup.js merely lives near
485
+ // onboarding — so the whole module became Archon-only to scope a goal to on the
486
+ // strength of one file that is not its domain. ADR 0314 narrows the QUESTION to
487
+ // the one the other three walls already ask: is the territory being scoped
488
+ // itself protected?
489
+ //
490
+ // So a protected CLI entrypoint no longer protects its owning module; protected
491
+ // territory (a directory, or a non-command file like server.js or CLAUDE.md)
492
+ // still does. The derivation stays COMPUTED from PROTECTED_GLOBS — no hand-set
493
+ // flag, no override list (ADR 0086 §7 / SEC #863). It can only ever SHRINK the
494
+ // protected set relative to the old rule, never grow it, because it filters a
495
+ // subset: no module can become protected that was not protected before.
496
+ //
497
+ // THE WALL THAT MATTERS DOES NOT MOVE. This decides who may CREATE a goal whose
498
+ // territory includes a protected path. It does not decide who may WRITE one: a
499
+ // sub-Metic builder touching scripts/gds/setup.js is still refused by
500
+ // checkPermissionPaths (goal-scope-check.js wall 2), exactly as before.
463
501
  function isProtected(globs) {
464
- return touchesOverlap(globs || [], PROTECTED_GLOBS);
502
+ return protectingGlobs(globs).some((g) => !isCommandPath(g));
465
503
  }
466
504
 
467
505
  // The frozen exported roster: key → { globs:[...], protected:bool } (derived).
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.911'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.912'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -12,6 +12,9 @@ const { PROTECTED_GLOBS } = require(path.join(ROOT, 'src', 'bongos', 'permission
12
12
  // the REAL consumer of the roster — asserted against directly so a wall assertion
13
13
  // here can never diverge from what the live goal-task-create gate computes.
14
14
  const gsc = require(path.join(ROOT, 'src', 'bongos', 'goal-scope-check.js'));
15
+ // the OLD protection rule, recomputed in-test so the shrink-only property below
16
+ // is proved against real globs rather than a copied literal (ADR 0314).
17
+ const { touchesOverlap } = require(path.join(ROOT, 'src', 'bongos', 'path-match.js'));
15
18
 
16
19
  test('roster covers the decided ~16 modules + kernel, each with non-empty globs', () => {
17
20
  const keys = scope.moduleKeys();
@@ -66,11 +69,21 @@ test('`protected` is DERIVED from the imported PROTECTED_GLOBS, never hand-set',
66
69
  //
67
70
  // So the set is spelled out. A module ARRIVING here is a deliberate line in a
68
71
  // diff; a module LEAVING is the one that must never happen quietly.
72
+ // NARROWED TWELVE -> NINE BY ADR 0314 (task 1004109). `onboarding`, `autonomy`
73
+ // and `copy-desk` left the set. Each derived protected on the strength of exactly
74
+ // one CLI it owns that sits on a protected path — setup.js, conductor-dispatch.js,
75
+ // copy-inventory.js — and owning the command is not owning the authority. Nothing
76
+ // was unprotected to achieve it: the globs still belong to those modules, the
77
+ // paths are still in PROTECTED_GLOBS, and a sub-Metic builder touching any of the
78
+ // three is still refused by the path wall (asserted below). What changed is who
79
+ // may CREATE a goal whose territory happens to include them.
80
+ //
81
+ // `builder-settings` STAYS, deliberately: its overlap is src/bongos/routes/me.js
82
+ // caught by the blanket src/bongos/routes/ prefix, not a command, so narrowing it
83
+ // needs the me.js strangler move or a blanket-glob change — out of scope here.
69
84
  const PROTECTED_MODULES = [
70
85
  'agents',
71
- 'autonomy',
72
86
  'builder-settings',
73
- 'copy-desk',
74
87
  'dev-box',
75
88
  'devsecops',
76
89
  'government',
@@ -78,7 +91,6 @@ const PROTECTED_MODULES = [
78
91
  'kernel',
79
92
  'lifecycle',
80
93
  'methodology',
81
- 'onboarding',
82
94
  ];
83
95
 
84
96
  test('the protected module set is exactly this, by name', () => {
@@ -133,6 +145,78 @@ test('isProtected actually consults PROTECTED_GLOBS (overlap, prefix-aware both
133
145
  assert.equal(scope.isProtected([]), false, 'empty scope is not protected');
134
146
  });
135
147
 
148
+ // ADR 0314 — A PROTECTED CLI ENTRYPOINT NO LONGER PROTECTS ITS OWNING MODULE.
149
+ //
150
+ // These three are the whole reason the ADR exists: each derived protected on the
151
+ // strength of exactly one command it owns that happens to sit on a protected
152
+ // path. Owning the command is not owning the authority. They are asserted by
153
+ // name rather than by count because a count passes on the wrong three.
154
+ const COMMAND_ONLY_MODULES = [
155
+ ['onboarding', 'scripts/gds/setup.js'],
156
+ ['autonomy', 'scripts/gds/conductor-dispatch.js'],
157
+ ['copy-desk', 'scripts/gds/copy-inventory.js'],
158
+ ];
159
+
160
+ test('a module protected ONLY by a CLI entrypoint it owns now derives non-protected', () => {
161
+ for (const [mod, cli] of COMMAND_ONLY_MODULES) {
162
+ const e = scope.scopeForModule(mod);
163
+ assert.ok(e, `fixture assumption: ${mod} is a roster key`);
164
+ assert.ok(e.globs.includes(cli), `fixture assumption: ${mod} still OWNS ${cli}`);
165
+ assert.ok(PROTECTED_GLOBS.includes(cli), `fixture assumption: ${cli} is still a protected surface`);
166
+ // The glob was not deleted and the path was not unprotected — only the
167
+ // question changed. That distinction is the ADR, so assert all three.
168
+ assert.equal(e.protected, false, `${mod} must no longer derive protected (ADR 0314)`);
169
+ }
170
+ });
171
+
172
+ test('...and creating a goal scoped to one is therefore no longer Archon-only', () => {
173
+ // The consequence the ADR was filed for: onboarding was Archon-mediated-join
174
+ // for a Metic because of setup.js. This is the assertion that would catch a
175
+ // revert of the behaviour while the name-pin above still passed.
176
+ for (const [mod] of COMMAND_ONLY_MODULES) {
177
+ assert.equal(scope.scopeIncludesProtected([mod]), false, `a goal scoped to ${mod} must not require Archon`);
178
+ }
179
+ });
180
+
181
+ test('protected TERRITORY still protects — a directory, and a non-command file', () => {
182
+ // The other side of the rule. If this ever goes false the ADR has been
183
+ // over-applied and the trust core is scopeable by anyone.
184
+ assert.equal(scope.isProtected(['migrations/']), true, 'a protected directory is territory');
185
+ assert.equal(scope.isProtected(['modules/government/']), true, 'a module namespace is territory');
186
+ assert.equal(scope.isProtected(['server.js']), true, 'a protected non-command file is territory');
187
+ assert.equal(scope.isProtected(['CLAUDE.md']), true, 'a protected doc is territory');
188
+ // A DIRECTORY under scripts/ is territory, not a command — the boundary the
189
+ // path test has to get right, since both start with 'scripts/'.
190
+ assert.ok(PROTECTED_GLOBS.includes('scripts/deploy/'), 'fixture assumption: scripts/deploy/ is protected');
191
+ assert.equal(scope.isProtected(['scripts/deploy/']), true, 'a protected directory under scripts/ is territory');
192
+ assert.ok(PROTECTED_GLOBS.includes('scripts/gds/hooks/'), 'fixture assumption: scripts/gds/hooks/ is protected');
193
+ assert.equal(scope.isProtected(['scripts/gds/hooks/']), true, 'scripts/gds/hooks/ is a directory, not a command');
194
+ });
195
+
196
+ test('a lone protected CLI computes non-protected, but a CLI PLUS territory does not', () => {
197
+ assert.equal(scope.isProtected(['scripts/gds/setup.js']), false, 'a lone protected CLI does not protect');
198
+ assert.equal(
199
+ scope.isProtected(['scripts/gds/setup.js', 'migrations/']), true,
200
+ 'one command must not mask owned territory — the mixed case is still protected',
201
+ );
202
+ });
203
+
204
+ test('ADR 0314 can only SHRINK the protected set, never grow it', () => {
205
+ // The safety property that makes this a narrowing and not a re-derivation: the
206
+ // new rule filters a SUBSET of the globs the old rule accepted, so no module
207
+ // can arrive in the protected set that was not already in it. Computed here
208
+ // from the OLD rule rather than from a literal, so it keeps holding as the
209
+ // roster changes.
210
+ const oldRule = (globs) => touchesOverlap(globs || [], PROTECTED_GLOBS);
211
+ for (const k of scope.moduleKeys()) {
212
+ const globs = scope.scopeForModule(k).globs;
213
+ if (scope.isProtected(globs)) {
214
+ assert.equal(oldRule(globs), true, `${k} is newly protected — the rule grew the set, which it must never do`);
215
+ }
216
+ }
217
+ });
218
+
219
+
136
220
  test('globsForModules flattens a scope set; unknown keys contribute nothing', () => {
137
221
  const g = scope.globsForModules(['ideas']);
138
222
  assert.ok(g.includes('modules/ideas/'), 'ideas owns its module directory (carved BV1.R78)');
@@ -9,7 +9,7 @@ import { createRequire } from 'node:module';
9
9
 
10
10
  const require = createRequire(import.meta.url);
11
11
  const P = require('../scripts/gds/provision.js');
12
- const { caddyWiringGap, checkCaddyWiring } = require('../scripts/gds/provision-net.js');
12
+ const { caddyWiringGap, checkCaddyWiring, ingressPlaneGap } = require('../scripts/gds/provision-net.js');
13
13
 
14
14
  const DOMAIN = 'mercury.example.com';
15
15
  // What `caddy adapt` prints: 2.7 writes the ask as a string, 2.8+ as a permission module.
@@ -63,11 +63,13 @@ const WEB_ENV = 'PORT=3010\nBONGOS_PUBLIC_ORIGIN=https://old.example.com\n';
63
63
 
64
64
  // `served` = the hosts Caddy's resolved config carries once a snippet is written — the
65
65
  // fake flips to them after the first .caddy write, like the real import would.
66
- function harness({ onDemand = ASK_27, importsSnippets = true, reloadFails = false } = {}) {
66
+ function harness({ onDemand = ASK_27, importsSnippets = true, reloadFails = false, noProxy = false } = {}) {
67
67
  const cmds = [], writes = [], statuses = [];
68
68
  let snippetWritten = null;
69
69
  const exec = (cmd, opts = {}) => {
70
70
  cmds.push({ cmd, opts });
71
+ // A plane with no proxy at all: the self-host image installs no caddy binary.
72
+ if (noProxy && /^caddy /.test(cmd)) { const e = new Error('Command failed'); e.stderr = Buffer.from('bash: caddy: command not found'); throw e; }
71
73
  if (cmd.startsWith('caddy adapt')) return { ok: true, stdout: adapted({ onDemand, hosts: importsSnippets && snippetWritten ? [snippetWritten] : [] }) };
72
74
  if (/cat\b.*web\.env/.test(cmd)) return { ok: true, stdout: WEB_ENV };
73
75
  if (/reload caddy/.test(cmd) && reloadFails) throw new Error('Job for caddy.service failed');
@@ -146,3 +148,75 @@ test('domain-attach: refused before the env patch without the ask; fails loudly
146
148
  assert.equal((await P.domainAttachInstance(activeStandalone, wired.deps)).ok, true);
147
149
  assert.notEqual(wired.cmds.find((c) => /reload caddy/.test(c.cmd)).opts.allowFail, true);
148
150
  });
151
+
152
+ // -- the plane has no reverse proxy AT ALL (task 1004017) -------------------
153
+ // ADR 0303 asks whether a box's Caddy is wired right. This is the prior question:
154
+ // the self-host image ships no proxy, so a child instance provisioned inside it gets
155
+ // a snippet write and a `systemctl reload caddy` that cannot work.
156
+
157
+ test('ingressPlaneGap: a plane that answers `caddy version` is wired; one that cannot is named as lacking a proxy', () => {
158
+ const cmds = [];
159
+ assert.equal(ingressPlaneGap((c) => { cmds.push(c); return { ok: true, stdout: 'v2.8.4' }; }), null);
160
+ assert.deepEqual(cmds, ['caddy version'], 'one cheap, unprivileged probe — no config read, no running server');
161
+ const boom = () => { const e = new Error('Command failed'); e.stderr = Buffer.from('bash: caddy: command not found'); throw e; };
162
+ const gap = ingressPlaneGap(boom);
163
+ assert.match(gap, /no reverse proxy/, 'names what the plane lacks');
164
+ assert.match(gap, /command not found/, 'carries the real reason');
165
+ });
166
+
167
+ test('ingressPlaneGap: only a MISSING BINARY proves there is no proxy — a transport failure says it could not confirm', () => {
168
+ // boxExec is `ssh <host> bash -s` for a co-tenant, so an unreachable box throws here too.
169
+ // Diagnosing that as "this platform has no proxy, provision elsewhere" would be a confident
170
+ // wrong answer, and the intent would burn its retry budget on it.
171
+ const unreachable = () => { const e = new Error('Command failed'); e.status = 255; e.stderr = Buffer.from('ssh: connect to host box.example.com port 22: Connection timed out'); throw e; };
172
+ const unsure = ingressPlaneGap(unreachable);
173
+ assert.match(unsure, /could not confirm a reverse proxy/);
174
+ assert.match(unsure, /Connection timed out/, 'the real cause leads, not a parenthetical');
175
+ assert.doesNotMatch(unsure, /has no reverse proxy/, 'never asserts a diagnosis it cannot support');
176
+ // Exit 127 still means "no caddy" across the ssh hop — ssh reports its OWN failures as 255
177
+ // and otherwise propagates the remote command's status.
178
+ const remoteMissing = () => { const e = new Error('Command failed'); e.status = 127; e.stderr = Buffer.from(''); throw e; };
179
+ assert.match(ingressPlaneGap(remoteMissing), /has no reverse proxy/);
180
+ const cmdExe = () => { const e = new Error("'caddy' is not recognized as an internal or external command"); throw e; };
181
+ assert.match(ingressPlaneGap(cmdExe), /has no reverse proxy/, 'cmd.exe words it differently');
182
+ });
183
+
184
+ test('provision: a DOMAINLESS child on a plane with no reverse proxy is refused before a single write', async () => {
185
+ // The hole ADR 0303 leaves open: its preflight is gated on inst.domain, so a domainless
186
+ // child walked all the way to step 6 and died on the snippet write instead of being refused.
187
+ const h = harness({ noProxy: true });
188
+ const r = await P.provisionInstance({ ...coTenant, domain: null }, h.deps);
189
+ assert.equal(r.ok, false);
190
+ assert.match(r.error, /no reverse proxy/);
191
+ assert.equal(h.writes.length, 0, 'nothing written — not the snippet, not web.env');
192
+ assert.ok(!h.ran(/reload caddy/), 'the reload that cannot work is never attempted');
193
+ assert.deepEqual(h.statuses, [], 'the instance never flips to provisioning');
194
+ });
195
+
196
+ test('provision: a domained child on a proxyless plane names the missing proxy, not a Caddyfile to edit', async () => {
197
+ const h = harness({ noProxy: true });
198
+ const r = await P.provisionInstance(coTenant, h.deps);
199
+ assert.equal(r.ok, false);
200
+ assert.match(r.error, /no reverse proxy/);
201
+ assert.doesNotMatch(r.error, /add 'import|add 'on_demand_tls/, 'a plane that will never have Caddy is not told to edit a Caddyfile it does not have');
202
+ assert.equal(h.writes.length, 0);
203
+ });
204
+
205
+ test('domain-attach: a plane with no reverse proxy is refused before the env patch', async () => {
206
+ const h = harness({ noProxy: true });
207
+ const r = await P.domainAttachInstance(activeStandalone, h.deps);
208
+ assert.equal(r.ok, false);
209
+ assert.match(r.error, /no reverse proxy/);
210
+ assert.equal(h.writes.length, 0, 'web.env untouched');
211
+ assert.ok(!h.ran(/reload caddy/));
212
+ });
213
+
214
+ test('a dry run never probes the plane, and a wired box still reloads Caddy loudly', async () => {
215
+ const dry = harness({ noProxy: true });
216
+ dry.deps.apply = false;
217
+ await P.provisionInstance(coTenant, dry.deps);
218
+ assert.ok(!dry.ran(/caddy version/), 'a dry run changes nothing and asks nothing of the box');
219
+ const wired = harness();
220
+ assert.equal((await P.provisionInstance(coTenant, wired.deps)).ok, true, 'ADR 0303 behaviour on a real box is unchanged');
221
+ assert.ok(wired.ran(/reload caddy/));
222
+ });
@@ -22,11 +22,39 @@ const q = require('../modules/security/artifact-scan/quarantine.js');
22
22
  const SHORT = os.tmpdir();
23
23
  const LONG = fs.realpathSync.native(SHORT);
24
24
 
25
- test('canonical expands an 8.3 short name — plain realpathSync does not', () => {
25
+ // eightDotThreeReason — why the 8.3 case cannot be evaluated on THIS box, or null (task 1004051).
26
+ //
27
+ // The assertion below it is a statement about one specific divergence: a Windows 8.3 short name,
28
+ // which plain fs.realpathSync does NOT expand. Asking "do the two spellings differ?" is a
29
+ // different question, and macOS is where the two come apart — there os.tmpdir() is /var/folders/…
30
+ // against a real /private/var/folders/…, a SYMLINK divergence that plain realpathSync resolves
31
+ // perfectly well, so the notEqual fails and the suite goes red over a fact that was never claimed
32
+ // about that platform. So ask for the 8.3 signature itself (a `~N` component) rather than
33
+ // inferring it from inequality.
34
+ //
35
+ // It must not read as a PASS either — the task 1003993 contract. This returns a REASON for
36
+ // node:test's own skip, never a bare early `return`, which prints as ✔ over a case that never
37
+ // ran. Same shape as shallowCloneReason() in tests/helpers.mjs (task 1004049); kept local
38
+ // because the 8.3 question belongs to this file alone.
39
+ function eightDotThreeReason() {
40
+ if (SHORT === LONG) {
41
+ return `${process.platform}: os.tmpdir() is already its canonical spelling, so there is no `
42
+ + 'second spelling to expand here';
43
+ }
44
+ if (!/~\d/.test(SHORT)) {
45
+ return `${process.platform}: os.tmpdir() (${SHORT}) diverges from its real path (${LONG}) for `
46
+ + 'a reason that is not an 8.3 short name — plain realpathSync resolves that kind, so the '
47
+ + '8.3 fact cannot be evaluated here';
48
+ }
49
+ return null;
50
+ }
51
+ const NO_8_3 = eightDotThreeReason();
52
+
53
+ test('canonical reports where os.tmpdir() really points, whatever its spelling', () => {
26
54
  assert.equal(q.canonical(SHORT), LONG);
27
- // The premise, stated so this test explains itself on a machine where it does not apply:
28
- // if the two spellings are identical here, the rest is trivially true rather than wrong.
29
- if (SHORT === LONG) return;
55
+ });
56
+
57
+ test('plain realpathSync does NOT expand an 8.3 short name', { skip: NO_8_3 ?? false }, () => {
30
58
  assert.notEqual(fs.realpathSync(SHORT), LONG, 'this is the fact the obvious fix gets wrong');
31
59
  });
32
60