@bongos/core 1.19.916 → 1.19.917

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.
@@ -185,6 +185,7 @@ const {
185
185
  getActiveClaimsForBuilderWithStale,
186
186
  getBuildersRoster,
187
187
  getClaimPublishedSha,
188
+ shippedTasksByPublishedSha,
188
189
  listConfirmedTasksForReconcile,
189
190
  mergeTask,
190
191
  needsRebaseTasksForBuilder,
@@ -330,6 +331,7 @@ module.exports = {
330
331
  listConfirmedTasksForReconcile, // task 1134 — server-owned publish reconciler feed
331
332
  setClaimPublishedSha, // task 1547 — record the SERVER-pushed tip for per-task land proof
332
333
  getClaimPublishedSha, // task 1547 — read the published tip for the reconcile gate
334
+ shippedTasksByPublishedSha, // task 1004090 / BV2.R15 — the reverse read, for removal attribution
333
335
  getActiveClaimsForBuilder,
334
336
  moduleScopeForActiveClaims, // goal 1000051 / R92 — dev-box task-scoped source access (ADR 0148 §2)
335
337
  activeClaimCountForBuilder, // task 1002729 — distinguishes "no claim" from "claim with an empty goal wall"
@@ -0,0 +1,332 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/removal-derivation.js — WHAT A SHIP DELETED, AND WHOSE WORK
4
+ // IT WAS (task 1004090 / BV2.R15, goal 1000095; ADR 0313).
5
+ //
6
+ // Brightness (R20) is the live share of a descent, so something has to be able to
7
+ // say that shipped work was later removed. Nothing recorded that: `shipped` is
8
+ // terminal, there is no reverted status, and `tasks.merged_into_task_id` is
9
+ // CHECK-constrained to abandoned rows. This file derives the signal.
10
+ //
11
+ // It produces a QUESTION, never a verdict. The rule in ./cascade.js files a task
12
+ // at backlog; a person confirms it (R16). Nothing here changes a status, and
13
+ // nothing here touches credits — ADR 0313 is explicit that the ledger is never
14
+ // rewritten, because refactors, renames and file moves all look like deletions to
15
+ // a diff and a derived-only signal would dim the sky continuously.
16
+ //
17
+ // ── WHY NOT THE SIGNAL THAT ALREADY EXISTS ──────────────────────────────────
18
+ //
19
+ // `netNegativeBonusAmount` is the nearest existing thing and is unusable, for
20
+ // three reasons ADR 0313 sets out:
21
+ // • It has no TARGET. `removed > added` over a whole ship says that deletion
22
+ // happened, never what was deleted or whose work it was. Brightness needs the
23
+ // target; the boolean carries none.
24
+ // • It is CLIENT-SUPPLIED — it reads `signals.diff_stats` from the grade request
25
+ // body, and `grader-score.js` already documents defending against a forged
26
+ // `diff_stats`.
27
+ // • Brightness sits beside money. Deriving it from a forgeable, target-less
28
+ // counter would be wrong even where it was convenient.
29
+ //
30
+ // So the signal comes from the SERVER's own view of the merge instead. Landing is
31
+ // server-mediated (ADR 0055): the server holds the authority and can ask GitHub
32
+ // what the pull request actually changed, rather than be told by the box that
33
+ // built it.
34
+
35
+ const api = require('../../src/module-api');
36
+ const githubPush = require('./github-push');
37
+
38
+ // How many deleted paths one ship will be attributed — and therefore the HARD
39
+ // CEILING on GitHub calls this derivation can make per ship. Each path costs one
40
+ // commit-history request, and they run on the server's shared push credential,
41
+ // which the publish path itself depends on: spending that budget to answer a
42
+ // question nobody asked for would be a poor trade.
43
+ //
44
+ // A ship that removes more than this is a sweep — a directory move, a vendored
45
+ // drop, a module deletion — where the proposal is least useful and the cost is
46
+ // highest. Eight is deliberately small: a genuine "this ship removed someone's
47
+ // feature" is a handful of files, and beyond that the truncation note tells a
48
+ // reader to weigh the ship as a move rather than a removal.
49
+ //
50
+ // The cap is on the ATTRIBUTION, not the detection: the deletion count is still
51
+ // reported honestly so the proposal can say its list is partial.
52
+ const MAX_ATTRIBUTED_PATHS = 8;
53
+
54
+ // listCommitsForPath — the shas of the commits that touched ONE path, newest
55
+ // first. The read behind attribution: a deleted file is blamed back to the commit
56
+ // that introduced it, and that sha is resolved through `claims.published_head_sha`.
57
+ //
58
+ // IT LIVES HERE RATHER THAN IN github-push.js, which is where its siblings live,
59
+ // because that file sits ON the 1500-line ratchet ceiling — the same reason
60
+ // `pr-assign.js` was carved out of it, and this module's CLAUDE.md says so in as
61
+ // many words. Its only consumer is the derivation below.
62
+ //
63
+ // WHY THE API AND NOT `git log`. The server keeps no persistent clone — pushBundle
64
+ // works in a temp dir off a bundle and throws it away — so there is no working tree
65
+ // to blame in. GitHub holds the history. Same reason getPullRequestFiles reads the
66
+ // REST files API rather than diffing locally.
67
+ //
68
+ // `until` bounds the walk to commits at or before the merge being examined, so a
69
+ // LATER commit that re-added the path cannot be mistaken for the one that
70
+ // introduced it.
71
+ //
72
+ // Caps at ONE page of 100. Attribution only needs to find a published tip, and a
73
+ // path with more than 100 commits above the one that introduced it is old enough
74
+ // that the introducing claim almost certainly predates `published_head_sha`
75
+ // (migrations/171) — in which case the correct output is no attribution at all,
76
+ // which is exactly what an exhausted page yields. Paging further would spend rate
77
+ // limit to reach the same silence.
78
+ async function listCommitsForPath({ path: filePath, until = null }, deps = {}) {
79
+ // resolveToken is injectable for the same reason fetchImpl and repoInfo are:
80
+ // this function is the one place a rate-limit response is classified, and that
81
+ // classification has to be testable without a live credential.
82
+ const token = deps.resolveToken ? await deps.resolveToken(deps) : await githubPush.resolveToken(deps);
83
+ if (!token) throw new Error('PUSH_UNCONFIGURED');
84
+ if (typeof filePath !== 'string' || !filePath) throw new Error('BAD_PATH');
85
+ const info = deps.repoInfo || api.repoInfo;
86
+ if (!info || info.error || !info.owner || !info.repo) throw new Error('NO_REPO_INFO');
87
+ const doFetch = deps.fetchImpl || fetch;
88
+ const qs = new URLSearchParams({ path: filePath, per_page: '100' });
89
+ if (until) qs.set('until', String(until));
90
+ const res = await doFetch(
91
+ `https://api.github.com/repos/${info.owner}/${info.repo}/commits?${qs.toString()}`,
92
+ { headers: githubPush.ghHeaders(token) }
93
+ );
94
+ // A path that has never existed is a 404 and an EMPTY history, not an error: the
95
+ // caller asks "who introduced this", and "nobody we can see" is an answer.
96
+ if (res.status === 404) return [];
97
+ // RATE LIMITING IS NOT A PER-PATH FAILURE — it is a statement about the whole
98
+ // credential, so it must not be retried and must not be walked past. GitHub
99
+ // signals it as 403 or 429 with the remaining count at zero (a 403 for any
100
+ // other reason keeps a non-zero remaining, or sends no header at all, and is
101
+ // treated as an ordinary failure). Thrown with a recognisable name so the
102
+ // caller can ABANDON the rest of the walk rather than spend the next seven
103
+ // requests learning the same thing.
104
+ if (res.status === 429 || res.status === 403) {
105
+ const remaining = res.headers && typeof res.headers.get === 'function'
106
+ ? res.headers.get('x-ratelimit-remaining')
107
+ : null;
108
+ if (res.status === 429 || remaining === '0') {
109
+ const e = new Error('RATE_LIMITED');
110
+ e.rateLimited = true;
111
+ throw e;
112
+ }
113
+ }
114
+ if (!res.ok) throw new Error(`STATUS_FETCH_FAILED ${res.status}`);
115
+ const commits = await res.json();
116
+ if (!Array.isArray(commits)) return [];
117
+ return commits.map((c) => c && c.sha).filter((sha) => typeof sha === 'string' && sha);
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // The pure half
122
+ // ---------------------------------------------------------------------------
123
+
124
+ // WHAT COUNTS AS REMOVED. ADR 0313 names three exclusions, and each is a status
125
+ // the REST files API already distinguishes — which is exactly why the derivation
126
+ // reads that API rather than counting lines:
127
+ //
128
+ // • 'renamed' — a rename or move is NOT a removal. Git's own rename detection
129
+ // resolves it and the work still stands. This is the exclusion
130
+ // that matters most: a directory reorganisation would otherwise
131
+ // propose a removal for every file in it.
132
+ // • 'modified' — a rewrite in place is NOT a removal. Lines changing is how
133
+ // code lives, and this is where a line-count heuristic
134
+ // (`removed > added`) goes wrong.
135
+ // • 'added' / 'copied' / 'changed' — nothing was lost.
136
+ //
137
+ // Only 'removed' survives. The third ADR exclusion — a cold body is not a remnant
138
+ // — is not expressible here: it is enforced downstream by attributing solely to
139
+ // tasks that actually SHIPPED (see shippedTasksByPublishedSha), because an idea
140
+ // declined at triage never ignited and nothing was built to lose.
141
+ //
142
+ // Pure: hand it the files array and it answers. No network, no clock, no DB.
143
+ function deletedPathsFrom(files) {
144
+ if (!Array.isArray(files)) return [];
145
+ const out = [];
146
+ for (const f of files) {
147
+ if (!f || typeof f !== 'object') continue;
148
+ // `deleted` is getPullRequestFiles' own mapping of status === 'removed'.
149
+ // Both are read, and they must AGREE: trusting `deleted` alone would follow a
150
+ // caller that set it by hand, and trusting `status` alone would miss a caller
151
+ // that shaped the row the other way. Where only one is present, it decides.
152
+ const status = typeof f.status === 'string' ? f.status.toLowerCase() : null;
153
+ const flagged = f.deleted === true;
154
+ const isRemoval = status ? status === 'removed' : flagged;
155
+ if (!isRemoval) continue;
156
+ if (typeof f.path !== 'string' || !f.path) continue;
157
+ out.push(f.path);
158
+ }
159
+ return [...new Set(out)];
160
+ }
161
+
162
+ // Group attributed paths by the task that published them, so the proposal reads
163
+ // as "this task lost these files" rather than a flat list a person has to sort.
164
+ // Pure. Insertion order is preserved, which keeps the rendered body stable for a
165
+ // given diff rather than reshuffling between runs.
166
+ function groupByTask(attributions) {
167
+ const byTask = new Map();
168
+ for (const a of Array.isArray(attributions) ? attributions : []) {
169
+ if (!a || a.taskId == null) continue;
170
+ const key = String(a.taskId);
171
+ if (!byTask.has(key)) byTask.set(key, { taskId: key, title: a.title || null, paths: [] });
172
+ const row = byTask.get(key);
173
+ if (a.path && !row.paths.includes(a.path)) row.paths.push(a.path);
174
+ }
175
+ return [...byTask.values()];
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // The impure half — one network read per deleted path, then one DB read
180
+ // ---------------------------------------------------------------------------
181
+
182
+ // Resolve deleted paths to the shipped tasks that published them.
183
+ //
184
+ // For each path: ask GitHub which commits touched it up to the merge, then look
185
+ // those shas up against `claims.published_head_sha`. The OLDEST matching commit is
186
+ // taken as the one that introduced the file — the newest would attribute a file to
187
+ // whoever last edited it, which is a different and wrong question.
188
+ //
189
+ // `shipTaskId` is excluded: a ship that deletes its OWN earlier file is a builder
190
+ // tidying up inside one piece of work, not a removal of someone else's.
191
+ //
192
+ // Everything is best-effort per path. One path whose history cannot be read costs
193
+ // that path its attribution and nothing else — the other paths still resolve, and
194
+ // a total failure yields an empty list, which files no proposal at all.
195
+ async function attributeDeletions({ paths, shipTaskId, until = null }, deps = {}) {
196
+ const listCommits = deps.listCommitsForPath || listCommitsForPath;
197
+ const lookup = deps.shippedTasksByPublishedSha;
198
+ const errors = [];
199
+ if (typeof lookup !== 'function') return { attributions: [], errors: ['no sha lookup provided'] };
200
+
201
+ const wanted = (Array.isArray(paths) ? paths : []).slice(0, MAX_ATTRIBUTED_PATHS);
202
+ const perPath = [];
203
+ for (const path of wanted) {
204
+ try {
205
+ // Newest first from the API; reversed so the OLDEST visible commit wins.
206
+ const shas = await listCommits({ path, until });
207
+ perPath.push({ path, shas: [...(Array.isArray(shas) ? shas : [])].reverse() });
208
+ } catch (e) {
209
+ // A rate-limit refusal ends the WALK, not just this path. Continuing would
210
+ // spend the remaining budget on requests already known to fail, on the same
211
+ // credential the publish path needs — the opposite of backing off. There is
212
+ // deliberately no retry and no sleep: this runs on the ship path, and
213
+ // waiting there to dim a star later is the wrong trade (ADR 0313).
214
+ if (e && e.rateLimited) {
215
+ errors.push('removal: GitHub rate limit reached — attribution abandoned for this ship');
216
+ break;
217
+ }
218
+ errors.push(`removal: history for ${path} unreadable (${e && e.message})`);
219
+ }
220
+ }
221
+
222
+ // ONE database read for every path's shas together, rather than one per path.
223
+ let shaToTask;
224
+ try {
225
+ shaToTask = await lookup(perPath.flatMap((p) => p.shas));
226
+ } catch (e) {
227
+ return { attributions: [], errors: [...errors, `removal: sha lookup failed (${e && e.message})`] };
228
+ }
229
+ if (!(shaToTask instanceof Map)) return { attributions: [], errors: [...errors, 'removal: sha lookup returned no map'] };
230
+
231
+ const attributions = [];
232
+ for (const { path, shas } of perPath) {
233
+ for (const sha of shas) {
234
+ const hit = shaToTask.get(sha);
235
+ if (!hit) continue;
236
+ if (shipTaskId != null && String(hit.taskId) === String(shipTaskId)) break;
237
+ attributions.push({ path, taskId: hit.taskId, title: hit.title, sha });
238
+ break;
239
+ }
240
+ }
241
+ return { attributions, errors };
242
+ }
243
+
244
+ // THE WHOLE DERIVATION, as the one call the cascade context resolver makes.
245
+ //
246
+ // NON-BLOCKING IS THE CONTRACT, not a nicety. This runs on the path a ship
247
+ // already takes, and ADR 0313 says so in as many words: "a derivation that errors
248
+ // files nothing and never fails the merge. Landing work is more important than
249
+ // dimming a star on time." So every failure mode below returns a report rather
250
+ // than throwing, and the caller treats an empty `removals` as "file nothing".
251
+ async function deriveRemovalsForShip({ taskId, prNumber, until = null }, deps = {}) {
252
+ const empty = (errors) => ({ removals: [], deletedCount: 0, truncated: false, errors });
253
+ try {
254
+ if (prNumber == null) return empty(['removal: no pull request for this ship — nothing to read']);
255
+
256
+ const getFiles = deps.getPullRequestFiles
257
+ || ((args) => githubPush.getPullRequestFiles(args));
258
+
259
+ let files;
260
+ try {
261
+ files = await getFiles({ number: Number(prNumber) });
262
+ } catch (e) {
263
+ return empty([`removal: PR files unreadable (${e && e.message})`]);
264
+ }
265
+
266
+ const deleted = deletedPathsFrom(files);
267
+ if (!deleted.length) return empty([]);
268
+
269
+ const { attributions, errors } = await attributeDeletions(
270
+ { paths: deleted, shipTaskId: taskId, until },
271
+ deps
272
+ );
273
+
274
+ return {
275
+ removals: groupByTask(attributions),
276
+ deletedCount: deleted.length,
277
+ truncated: deleted.length > MAX_ATTRIBUTED_PATHS,
278
+ errors,
279
+ };
280
+ } catch (e) {
281
+ return empty([`removal: ${e && e.message}`]);
282
+ }
283
+ }
284
+
285
+ // removalCandidatesForTask — the whole resolver the ship listener wires, as ONE
286
+ // testable function rather than a closure inside the listener.
287
+ //
288
+ // IT LIVES HERE BECAUSE THE CLOSURE VERSION SHIPPED A DEAD FEATURE. Written
289
+ // inline in routes/lifecycle.js it read `pr.number` from findTaskPullRequest,
290
+ // which returns `pr_number` — so `prNumber` was null on every real ship,
291
+ // deriveRemovalsForShip short-circuited every time, and the rule could never
292
+ // fire in production. Every test injected `removalCandidates` or
293
+ // `getPullRequestFiles` directly, so the whole suite passed over a feature that
294
+ // did nothing: the parts agreed with each other and none of them was wired to
295
+ // the part that was wrong.
296
+ //
297
+ // A closure inside a listener registration is not reachable from a test without
298
+ // rebuilding the router and intercepting the registration. Naming it makes the
299
+ // field read testable, and leaves the listener a single line that cannot hide a
300
+ // second one.
301
+ //
302
+ // Resolving the PR here rather than inside deriveRemovalsForShip keeps THAT
303
+ // function free of the task->PR question, which github-push already answers.
304
+ // A task with no findable PR derives nothing, which files nothing.
305
+ async function removalCandidatesForTask(task, deps = {}) {
306
+ const empty = { removals: [], deletedCount: 0, truncated: false, errors: [] };
307
+ const findPr = deps.findTaskPullRequest
308
+ || ((args) => githubPush.findTaskPullRequest(args));
309
+ let prNumber = null;
310
+ try {
311
+ const pr = await findPr({ taskId: task && task.id });
312
+ // `pr_number`, NOT `pr.number` — findTaskPullRequest returns
313
+ // { branch, pr_number, pr_state, merged }. Pinned by test, because reading
314
+ // the wrong field here is silent: it yields null, which is a legal "no PR"
315
+ // answer, so the feature simply never runs and nothing ever complains.
316
+ prNumber = pr && pr.pr_number != null ? pr.pr_number : null;
317
+ } catch (_) {
318
+ // Never let the PR lookup cost the ship. Same contract as everything below.
319
+ return empty;
320
+ }
321
+ return deriveRemovalsForShip({ taskId: task && task.id, prNumber }, deps);
322
+ }
323
+
324
+ module.exports = {
325
+ MAX_ATTRIBUTED_PATHS,
326
+ removalCandidatesForTask,
327
+ listCommitsForPath,
328
+ deletedPathsFrom,
329
+ groupByTask,
330
+ attributeDeletions,
331
+ deriveRemovalsForShip,
332
+ };
@@ -28,6 +28,7 @@ const api = require('../../../src/module-api');
28
28
  const lifecycle = require('../lifecycle');
29
29
  const db = require('../db');
30
30
  const cascade = require('../cascade');
31
+ const removalDerivation = require('../removal-derivation');
31
32
 
32
33
  // task 1003549 — the cross-role cascade listener. `task.shipped` already exists
33
34
  // as a kernel event (emitted post-commit by the publish reconciler and the ship
@@ -78,6 +79,29 @@ function registerSeams() {
78
79
  // HOLDS a deploy can never be read differently. At 'off' the artist-review
79
80
  // rule files nothing.
80
81
  artistGateMode: () => api.artistGate.artistGateMode(),
82
+ // WHAT THIS SHIP DELETED (task 1004090 / BV2.R15, ADR 0313). The derivation
83
+ // asks GitHub what the pull request actually changed and blames the deleted
84
+ // paths back through `claims.published_head_sha` — the SERVER's own view of
85
+ // the merge, never the client-supplied `diff_stats` the net-negative bonus
86
+ // reads, which is target-less and already known-forgeable.
87
+ //
88
+ // Resolving the PR number here rather than inside the derivation keeps that
89
+ // file free of the task->PR question, which github-push already answers.
90
+ // A task with no findable PR derives nothing, which files nothing.
91
+ //
92
+ // Every failure is swallowed into an empty result: this runs on the ship
93
+ // path, and ADR 0313 is explicit that landing work matters more than dimming
94
+ // a star on time.
95
+ //
96
+ // The resolver itself is `removalCandidatesForTask`, NOT a closure here. It
97
+ // was a closure, and it read the wrong field off findTaskPullRequest
98
+ // (`number` for `pr_number`), which made the whole rule dead in production
99
+ // while every test passed — because every test injected this dep. A closure
100
+ // inside a listener registration is not reachable from a test; a named
101
+ // function is, and this line is now too small to hide a bug of its own.
102
+ removalCandidates: (task) => removalDerivation.removalCandidatesForTask(task, {
103
+ shippedTasksByPublishedSha: (shas) => db.shippedTasksByPublishedSha(shas),
104
+ }),
81
105
  log: api.logger ? api.logger('lifecycle') : null,
82
106
  }));
83
107
  }
@@ -194,15 +194,32 @@ const shippedCopy = (over = {}) => ({
194
194
  ...over,
195
195
  });
196
196
 
197
- await test('the artist review is the one production rule, keyed and disciplined', () => {
198
- assert.equal(cascade.CASCADES.length, 1);
199
- const r = cascade.CASCADES[0];
200
- assert.equal(r.key, 'artist-review-on-ship');
197
+ await test('the artist review is keyed and disciplined, and the table stays well-formed', () => {
198
+ // This pinned CASCADES.length === 1 until task 1004090 appended the removal
199
+ // proposal (BV2.R15). A count is the wrong pin for a table whose whole contract
200
+ // is "extend by appending an entry" (ADR 0242) — it fails on the one change it
201
+ // is meant to permit, and says nothing about WHICH rules are there.
202
+ //
203
+ // So it pins the rule by KEY instead, and pins the SET of keys separately below:
204
+ // a rule vanishing and a rule being added are different events and should fail
205
+ // differently.
206
+ const r = cascade.CASCADES.find((x) => x.key === 'artist-review-on-ship');
207
+ assert.ok(r, 'the artist review rule is gone from CASCADES');
201
208
  assert.equal(r.discipline, 'artist', 'the review is FOR the artist, whatever role shipped the work');
202
209
  assert.equal(r.kind, 'unclassified', 'a valid kind — not the invalid "chore" the spec used to default to');
203
210
  assert.deepEqual(cascade.validateCascades(), [], 'and the shipped table is well-formed');
204
211
  });
205
212
 
213
+ await test('the production rule set is exactly what is declared', () => {
214
+ // The other half of the pin above: adding a rule is a deliberate act, so it
215
+ // should have to be recorded here. Duplicate keys would collide on source_ref
216
+ // and silently suppress one another, which validateCascades already refuses.
217
+ assert.deepEqual(
218
+ cascade.CASCADES.map((r) => r.key).sort(),
219
+ ['artist-review-on-ship', 'removal-proposed-on-ship'],
220
+ );
221
+ });
222
+
206
223
  await test('a copy/visual ship generates the two-step artist review', () => {
207
224
  const { matched } = cascade.cascadesFor(shippedCopy());
208
225
  assert.equal(matched.length, 1);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.916",
3
+ "version": "1.19.917",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.916",
9
+ "version": "1.19.917",
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.916",
3
+ "version": "1.19.917",
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",
@@ -201,6 +201,56 @@ function grantInstanceRepoReadCmd(inst, { privileged = false } = {}) {
201
201
  return `${privileged ? 'sudo ' : ''}usermod -aG ${APP_USER} ${instanceUser(inst)}`;
202
202
  }
203
203
 
204
+ // Can this plane give a child instance its OWN unix account AT ALL? (task 1004018, ADR 0317)
205
+ //
206
+ // ensureInstanceUserCmd above needs uid 0 — directly, or through the `privileged` sudo
207
+ // prefix. The self-host image runs `USER node` and installs no sudo, so there it cannot
208
+ // run, and every child on that plane would share one uid: exactly the /proc/<pid>/environ
209
+ // vector ADR 0281 exists to close. Probed, not configured — a runner either reaches root
210
+ // on this plane or it does not, and an operator who wants provisioning to proceed needs
211
+ // privilege, not a flag that says to pretend.
212
+ //
213
+ // The probe runs the SAME escalation the mutation will, so a pass is evidence rather than
214
+ // inference. Read-only, no mutation, and no pipeline: a pipeline's exit status is the last
215
+ // stage's, which would erase the difference below.
216
+ //
217
+ // A shell that RAN AND ANSWERED is the only failure that proves the plane cannot. Under
218
+ // remote exec boxExec is `ssh <host> bash -s`, so an unreachable co-hosting box throws
219
+ // here too — and answering that with "this plane cannot create accounts, provision
220
+ // elsewhere" would be a confident wrong diagnosis that burns the intent's retry budget on
221
+ // a network blip. Exit 1 (not root) and 127 (no sudo binary) are read as proof; ssh
222
+ // reports its own failures as 255, so both still mean what they say across the hop.
223
+ const ACCOUNT_PROOF_STATUS = new Set([1, 127, 9009]);
224
+ function accountPlaneGap(exec, { privileged = false } = {}) {
225
+ // sh -c, not a bare test: under sudo the outer (unprivileged) shell would expand
226
+ // $(id -u) before sudo ever ran, and the probe would answer about the wrong user.
227
+ const cmd = `${privileged ? 'sudo ' : ''}sh -c 'test "$(id -u)" = 0'`;
228
+ try { exec(cmd); return null; } catch (e) {
229
+ // e.stderr is a zero-length Buffer when the command failed SILENTLY — truthy, and
230
+ // String()s to '' — so it has to be length-checked or it shadows the only cause there
231
+ // is. `test` exiting 1 writes nothing, and that is this probe's commonest refusal.
232
+ const stderr = e && e.stderr && e.stderr.length ? e.stderr : null;
233
+ const why = String(stderr || (e && e.message) || e).trim().slice(0, 200) || `exit status ${e && e.status}`;
234
+ if (!ACCOUNT_PROOF_STATUS.has(e && e.status)) {
235
+ return `could not confirm the runner can create a per-instance unix account on this plane: '${cmd}' failed (${why}). Every instance is given its own account, so provisioning refuses rather than assume — re-run once the box answers.`;
236
+ }
237
+ return `this plane cannot create a per-instance unix account, so every child instance would run as one uid and could read its siblings' secrets: '${cmd}' failed (${why}). Provision from a host where the runner is root or holds sudo (PROVISION_SUDO=1) — a shared uid is the isolation ADR 0281 exists to provide, not a detail.`;
238
+ }
239
+ }
240
+
241
+ // The preflight around accountPlaneGap: the narration its sibling preflights carry,
242
+ // including the dry-run line without which the plan prints step 1c's useradd as though it
243
+ // will work. Returns the refusal, or null to proceed. `dedicated` skips it — that account
244
+ // is created by cloud-init, which runs as root. Lives here beside the account it guards.
245
+ function accountPreflight({ exec, apply, log, shape, privileged = false, probe = accountPlaneGap }) {
246
+ if (shape === 'dedicated') return null;
247
+ if (!apply) { log(' [preflight] would probe whether the runner can create this instance\'s own unix account'); return null; }
248
+ const missing = probe(exec, { privileged });
249
+ if (missing) return `Account preflight failed: ${missing}`;
250
+ log(' [preflight] the runner can create the instance\'s own unix account ✓');
251
+ return null;
252
+ }
253
+
204
254
  // Create-or-rotate the instance's PG login role and hand it its OWN database — and
205
255
  // nothing else. Three statements, one psql, ON_ERROR_STOP:
206
256
  // 1. CREATE ROLE ... LOGIN PASSWORD, or ALTER ... PASSWORD when it already exists (the
@@ -1020,6 +1070,8 @@ async function refreshStandaloneCorePin(inst, deps) {
1020
1070
 
1021
1071
  module.exports = {
1022
1072
  APP_USER,
1073
+ accountPlaneGap,
1074
+ accountPreflight,
1023
1075
  CORE_PKG_DIR,
1024
1076
  INSTANCE_GITIGNORE,
1025
1077
  alreadyScaffolded,
@@ -50,7 +50,7 @@ const cp = require('node:child_process');
50
50
  const crypto = require('node:crypto');
51
51
 
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
- 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');
53
+ const { APP_USER, accountPreflight, 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
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');
@@ -276,6 +276,12 @@ async function provisionInstance(inst, deps) {
276
276
  // skipped 0303 entirely, reached step 6 and died on the snippet write.
277
277
  const ingressGap = ingressPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, probe: deps.ingressPlaneGap });
278
278
  if (ingressGap) return { ok: false, error: ingressGap };
279
+ // Account preflight (task 1004018, ADR 0317). The third and last PLANE question — can this
280
+ // box give the child its own unix account? — before 0303 below asks the per-DOMAIN one. A
281
+ // plane that cannot would run every child as one uid, the ADR 0281 vector, and step 1c's
282
+ // useradd would crash mid-provision instead of refusing.
283
+ const accountGap = accountPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, privileged, probe: deps.accountPlaneGap });
284
+ if (accountGap) return { ok: false, error: accountGap };
279
285
  // Caddy preflight (task 1002860, ADR 0303): a box whose base Caddyfile has no tls-check ask
280
286
  // can never certify this domain, so refuse before touching it. Injectable like the DNS check.
281
287
  const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring;
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.916'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.917'; // 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');