@bongos/core 1.19.936 → 1.19.938

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.
Files changed (43) hide show
  1. package/.bongos-core.json +69 -44
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md +2 -0
  8. package/docs/api/openapi.json +91 -2
  9. package/docs/api-reference.md +3 -2
  10. package/docs/module-api-changelog.md +4 -0
  11. package/docs/recipes/self-host.md +1 -1
  12. package/modules/government/board.js +31 -0
  13. package/modules/government/db.js +33 -0
  14. package/modules/government/migrations/government_018_passed_items_index.sql +31 -0
  15. package/modules/government/routes/government.js +7 -0
  16. package/modules/hall-ui/public/deploy.js +1 -1
  17. package/modules/ideas/inbox.js +11 -0
  18. package/modules/ideas/migrations/ideas_010_goal_ratification.sql +103 -0
  19. package/modules/ideas/module.json +1 -0
  20. package/modules/ideas/ratify.js +288 -1
  21. package/modules/ideas/routes/ratify-goal.js +163 -0
  22. package/modules/ideas/templates.js +16 -4
  23. package/modules/lifecycle/goal-from-idea.js +131 -0
  24. package/modules/lifecycle/lifecycle.js +14 -0
  25. package/modules/provisioning/core-upgrade.js +1 -1
  26. package/modules/provisioning/routes/core-upgrade.js +1 -1
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/scripts/gds/audit-authorship.sh +33 -0
  30. package/scripts/gds/db-backup-nightly.sh +1 -1
  31. package/scripts/gds/instance-apex.js +11 -1
  32. package/scripts/gds/provision-config.js +3 -2
  33. package/scripts/gds/provision-core-upgrade.js +7 -7
  34. package/scripts/gds/provision-process.js +2 -2
  35. package/scripts/gds/provision-repo.js +35 -10
  36. package/scripts/gds/provision-units.js +3 -3
  37. package/scripts/gds/provision.js +6 -3
  38. package/src/module-api.js +1 -1
  39. package/tests/core_upgrade_runner.mjs +10 -7
  40. package/tests/government_seed.mjs +1 -0
  41. package/tests/idea_goal_ratification.mjs +553 -0
  42. package/tests/provision.mjs +44 -7
  43. package/tests/provision_account_identity.mjs +16 -0
@@ -0,0 +1,163 @@
1
+ // modules/ideas/routes/ratify-goal.js — POST /inbox/:id/ratify-goal, the route
2
+ // that turns a Full Idea the board has carried into a GOAL (task 1004096 /
3
+ // BV2.R17, spec R23, goal 1000095, criterion `ideator-full-idea-shapes-the-space`).
4
+ //
5
+ // WHY IT IS ITS OWN FILE, and not one more route in inbox.js. That file sits at
6
+ // the 1,500-line size ratchet (`scripts/gds/fitness-ratchets.js` OVERSIZE_LINES)
7
+ // and this route pushed it 24 lines over. Trimming back under would have left it
8
+ // at ~1,497 — a landmine for whoever adds the next line — so the route moved
9
+ // out instead. It is the same call `trail-descent.js`, `trail-size.js` and
10
+ // `projection.js` were carved out on, and projection.js's own header names the
11
+ // failure mode being avoided: a ceiling that has "started deciding the design
12
+ // rather than the domain".
13
+ //
14
+ // It is also a clean cut on the domain, not only on the line count. Everything
15
+ // in inbox.js is INTAKE — a thought entering the system and becoming work, or
16
+ // not. This is the opposite end: a thought that has already been through the
17
+ // Board Room, becoming the structure other people's work lands in. The
18
+ // criterion half of ratification (`POST /inbox/:id/ratify`) stays in inbox.js
19
+ // where it has always been; moving it would be a rename with no argument behind
20
+ // it, and the two halves are documented as deliberately asymmetrical anyway
21
+ // (see `../ratify.js`).
22
+ //
23
+ // ROUTE ORDER IS NOT AT RISK HERE, unlike the literal paths inbox.js warns
24
+ // about. Express matches whole paths, so `/inbox/:id` cannot swallow
25
+ // `/inbox/:id/ratify-goal` however the two files are mounted. A literal segment
26
+ // AFTER a param — the `/tasks/removal-proposals` trap the lifecycle map
27
+ // records — is a different shape from a literal segment after a param plus a
28
+ // further segment, which is what this is.
29
+ //
30
+ // MODULE BOUNDARY (ADR 0093 §1): auth gates and request validators reach through
31
+ // the published doorway `src/module-api.js`; `../inbox` is an intra-module
32
+ // sibling. Never a core internal, never another module.
33
+
34
+ const express = require('express');
35
+ const api = require('../../../src/module-api');
36
+ const inbox = require('../inbox');
37
+
38
+ const log = api.logger('ideas');
39
+ const { validateOrRespond, LIMITS, parseId } = api;
40
+
41
+ module.exports = function buildRatifyGoalRouter() {
42
+ const router = express.Router();
43
+
44
+ // The fields a ratify-goal refusal may put on the wire — an ALLOWLIST, not a
45
+ // `{ ...err }` spread. Every shape thrown by `ratifyFullIdeaAsGoal` today is a
46
+ // plain typed refusal this code constructs itself, so a spread is harmless
47
+ // TODAY; what it is not is harmless tomorrow. A later call site that let a
48
+ // real exception carrying a `.code` fall into this catch would echo whatever
49
+ // else that object holds, and the refusals here already spread two internal
50
+ // objects into themselves (the composer's result, the lifecycle port's
51
+ // verdict) — so `ok`, `description` and `criterionMd` are all one edit away
52
+ // from the response body. Naming them explicitly costs nothing and means a
53
+ // new internal field is invisible by default rather than published by default.
54
+ const RATIFY_GOAL_DETAIL_FIELDS = [
55
+ 'message', // a gate's own sentence, written to be read by the caller
56
+ 'reason', // why the composer refused
57
+ 'field', // which answer was missing
58
+ 'template_key', // which vintage has no goal shape
59
+ 'grade', // what the idea actually is, when it is not Full
60
+ 'status', // the idea's status, when it is no longer open
61
+ 'version_status', 'unknown', 'valid', 'scope_modules', 'detail',
62
+ ];
63
+ const ratifyGoalDetail = (err) => {
64
+ const out = {};
65
+ for (const k of RATIFY_GOAL_DETAIL_FIELDS) {
66
+ if (err[k] !== undefined) out[k] = err[k];
67
+ }
68
+ return out;
69
+ };
70
+
71
+ // BV2.R17 (task 1004096, spec R23, goal 1000095) — ratify a FULL IDEA into a
72
+ // GOAL, atomically:
73
+ // - INSERT goals (title from the idea, description = its five answers)
74
+ // - INSERT done_when_criteria on that goal from the "what does a successful
75
+ // implementation look like" answer
76
+ // - UPDATE idea_inbox SET status='promoted', reviewed_at=now()
77
+ // - INSERT ideas_goal_ratifications — who ratified, under which board
78
+ // decision, with the author's own recommendation recorded beside it
79
+ // All of it in one transaction; a partial failure rolls back to an open idea.
80
+ //
81
+ // A SEPARATE ROUTE FROM /ratify, not a mode of it. The two take different
82
+ // subjects (a `criterion-proposal` idea whose body_md is JSON vs. a graded Full
83
+ // Idea whose answers are prose), produce different rows, and differ on whether
84
+ // the board has a say. One route with a discriminator would have to document
85
+ // which half of its own body applies, and a caller who got the discriminator
86
+ // wrong would be told about a field rather than about the act.
87
+ //
88
+ // THE SCOPE AND THE VERSION COME FROM THE REQUEST because nothing else can
89
+ // supply them: no prose answer says which version a goal belongs to or which
90
+ // modules it may touch. THE GATES DO NOT come from the request — the handler
91
+ // passes the caller's LIVE rank through to the lifecycle port, which applies
92
+ // every check POST /goals applies (unknown module key, protected scope →
93
+ // Archon, a building version's goal set closed without a named Archon
94
+ // admission). `idea.ratify`'s floor is Metic, so those gates are load-bearing
95
+ // here rather than ceremonial.
96
+ //
97
+ // Statuses: 400 a bad body / an unratifiable idea / an unmappable template ·
98
+ // 403 a rank gate · 404 no such idea · 409 the idea is not open, the board has
99
+ // not passed it, the version will not take a goal, or it is already ratified.
100
+ router.post(
101
+ '/inbox/:id/ratify-goal',
102
+ api.requireBuilder,
103
+ api.requirePermission('idea.ratify'),
104
+ async (req, res) => {
105
+ if (validateOrRespond(req, res, {
106
+ target_version: { required: true, type: 'string', minLength: 1, maxLength: 64 },
107
+ scope_modules: { type: 'array', maxItems: 64, itemsType: 'string' },
108
+ // Mirrors POST /goals: the PRESENCE of a reason IS the override, so one
109
+ // cannot be requested without stating a cause, and minLength 8 because
110
+ // 'ok' is not a reason.
111
+ admit_reason: { type: 'string', minLength: 8, maxLength: 500 },
112
+ notes_md: { type: 'string', maxLength: LIMITS.NOTES },
113
+ })) return;
114
+ const id = parseId(req, res, { code: 'bad_id' });
115
+ if (id === null) return;
116
+ try {
117
+ const result = await inbox.ratifyFullIdeaAsGoal(id, req.builder.id, {
118
+ targetVersion: req.body.target_version,
119
+ scopeModules: req.body.scope_modules || [],
120
+ admitReason: (req.body.admit_reason || '').trim() || null,
121
+ // The live rank read off the authenticated request, never a body field
122
+ // (ADR 0016) — the port denies when it is absent.
123
+ actorRank: req.builder.rank,
124
+ notesMd: typeof req.body.notes_md === 'string' ? req.body.notes_md : '',
125
+ });
126
+ res.status(201).json({ ok: true, ...result });
127
+ } catch (err) {
128
+ if (err && err.code) {
129
+ // A refusal that already carries its own status is one the lifecycle
130
+ // port named (the goal-creation gates) — rendered as given rather than
131
+ // re-mapped here, so the two files cannot disagree about what a gate
132
+ // returns.
133
+ if (Number.isInteger(err.status)) {
134
+ return res.fail(err.code, err.status, ratifyGoalDetail(err));
135
+ }
136
+ const status =
137
+ err.code === 'IDEA_NOT_FOUND' ? 404 :
138
+ err.code === 'NOT_A_FULL_IDEA' ? 400 :
139
+ err.code === 'CANNOT_COMPOSE_GOAL' ? 400 :
140
+ err.code === 'TARGET_VERSION_REQUIRED' ? 400 :
141
+ err.code === 'BAD_ID' ? 400 :
142
+ err.code === 'BAD_RATIFIER' ? 400 :
143
+ err.code === 'IDEA_NOT_OPEN' ? 409 :
144
+ err.code === 'BOARD_HAS_NOT_PASSED' ? 409 :
145
+ err.code === 'NO_LIFECYCLE' ? 503 :
146
+ err.code === 'NO_BOARD' ? 503 :
147
+ 500;
148
+ if (status !== 500) return res.fail(err.code, status, ratifyGoalDetail(err));
149
+ }
150
+ // The UNIQUE trips are the double-ratification guards: one on the audit
151
+ // row's idea_id, one on (version_id, criterion_id). Either means this
152
+ // idea has already become a goal.
153
+ if (err && err.code === '23505') {
154
+ return res.fail('IDEA_ALREADY_RATIFIED', 409, { detail: 'This idea has already been ratified into a goal.' });
155
+ }
156
+ log.error('[gds] POST /inbox/:id/ratify-goal', err);
157
+ res.fail('ratify_goal_failed', { status: 500, message: 'internal error' });
158
+ }
159
+ }
160
+ );
161
+
162
+ return router;
163
+ };
@@ -35,13 +35,25 @@
35
35
  // resolve its own registry and pass it through; nothing below cares where a
36
36
  // registry came from. No customization UI, no config plumbing — just the seam.
37
37
  //
38
- // Same-module import: DEFAULT_TEMPLATE_KEY and the payload cap live in inbox.js
39
- // (declared there for the route validators, mirroring the DB constraints) — one
40
- // source, so this file can never drift from what the store accepts.
38
+ // Same-module import: DEFAULT_TEMPLATE_KEY and the payload cap live in
39
+ // projection.js (declared there for the route validators, mirroring the DB
40
+ // constraints) — one source, so this file can never drift from what the store
41
+ // accepts.
42
+ //
43
+ // IT READS THEM FROM projection.js, NOT FROM inbox.js, and that is load-bearing
44
+ // rather than tidy. Both names are the same constants either way — inbox.js only
45
+ // re-exports what the 1004115 carve moved down to projection.js — but inbox.js
46
+ // requires ./ratify, so importing them from there makes this file part of a
47
+ // require CYCLE the moment anything in the ratify half wants a template
48
+ // (BV2.R17 wants exactly that: a Full Idea's answers are ordered by the
49
+ // declaration here). projection.js requires NOTHING, by design — its own header
50
+ // calls that out as the property that keeps the module acyclic — so reading the
51
+ // constants from the file that declares them is both shorter and the only
52
+ // spelling that stays acyclic.
41
53
 
42
54
  'use strict';
43
55
 
44
- const { DEFAULT_TEMPLATE_KEY, FIELD_VALUES_MAX } = require('./inbox');
56
+ const { DEFAULT_TEMPLATE_KEY, FIELD_VALUES_MAX } = require('./projection');
45
57
 
46
58
  // The five base fields, verbatim from the owner's brief. `key` is the stable
47
59
  // snake_case identity `field_values` is stored under (renaming a label is free;
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/goal-from-idea.js — creating the GOAL a ratified Full Idea
4
+ // becomes, inside somebody else's transaction (task 1004096 / BV2.R17, spec R23,
5
+ // goal 1000095, criterion `ideator-full-idea-shapes-the-space`).
6
+ //
7
+ // WHY THIS LIVES IN LIFECYCLE AND NOT IN modules/ideas. `goals`, `versions` and
8
+ // `done_when_criteria` are this module's tables, and the ideas module may not
9
+ // import lifecycle (the one-way module rule, ADR 0083). The alternative was to
10
+ // widen the `lifecycle` port with four primitives — getVersion, createGoal,
11
+ // createCriterion, authorizeGoalCreate — and let the ideas module compose them.
12
+ // That is the shape to avoid: composing them IS the policy, and the policy is
13
+ // "may a goal be created here, by this rank, with this scope", which is a
14
+ // lifecycle judgement. Handing a caller the four pieces hands them four chances
15
+ // to leave one out, and the one most easily left out is the gate.
16
+ //
17
+ // SO THIS IS ONE PORT CALL THAT EITHER CREATES BOTH ROWS OR NAMES ITS REFUSAL.
18
+ // The ideas module supplies the DATA a ratification carries (title, the five
19
+ // answers, the version and scope the board chose) and the actor's live rank; it
20
+ // never gets to decide whether the create is allowed.
21
+ //
22
+ // IT RUNS IN THE CALLER'S TRANSACTION (`deps.client`, required — see below), for
23
+ // the ensureMaintenanceGoal reason: a goal committed on its own connection would
24
+ // survive a rolled-back ratification as an empty goal nobody asked for, pointing
25
+ // at an idea still sitting `open` in the inbox.
26
+ //
27
+ // EVERY GATE POST /goals APPLIES IS APPLIED HERE, and that is the point of the
28
+ // file rather than a courtesy. `idea.ratify`'s floor is METIC (ADR 0157 lowered
29
+ // it from archon), while `POST /goals` refuses a protected-`scope_modules` goal
30
+ // to anyone below Archon and refuses to grow a BUILDING version's goal set at
31
+ // all without a named Archon admission. A ratification path that skipped either
32
+ // would be a Metic-reachable way around both — the scope wall and the R03 gate
33
+ // would still be in the code and no longer in the way of anything.
34
+
35
+ const api = require('../../src/module-api');
36
+ const db = require('./db.js');
37
+ const { authorizeGoalCreate } = require('./routes/goal-route-authz.js');
38
+
39
+ const { isModuleKey, scopeIncludesProtected, moduleKeys } = api.moduleScopeMap;
40
+
41
+ // A typed refusal, never a throw: the caller is inside a transaction it has to
42
+ // roll back itself, and an exception thrown across a port is the thing that
43
+ // makes a rollback easy to forget. `status` travels with the refusal so the
44
+ // route renders it without a second mapping table.
45
+ function refuse(code, status, extra = {}) {
46
+ return { ok: false, code, status, ...extra };
47
+ }
48
+
49
+ // createRatifiedGoal — the goal + its first done-when criterion, in the caller's
50
+ // transaction, or a named refusal.
51
+ //
52
+ // `criterionMd` is REQUIRED and is not a convenience. The criterion is what makes
53
+ // the created row a goal in this project's sense rather than an empty container:
54
+ // ADR 0263's close gate reads done-when criteria, so a goal born with none can
55
+ // never be achieved and holds its version open forever. BV2.R17's whole claim is
56
+ // that a Full Idea's "what does a successful implementation look like" answer IS
57
+ // that criterion — so the two rows are one act, and this function will not make
58
+ // the first without the second.
59
+ //
60
+ // `criterionRef` is the stable `criterion_id` text. The caller derives it from
61
+ // the source idea (`idea-<n>`), which makes the UNIQUE (version_id,
62
+ // criterion_id) constraint a second, database-level guard against ratifying the
63
+ // same idea twice onto the same version — the `proposal-<idea_id>` trick
64
+ // ratifyCriterionProposal already uses, for the same reason.
65
+ async function createRatifiedGoal(
66
+ { versionId, title, description = null, subtitle = null, scopeModules = [], createdBy = null,
67
+ criterionMd, criterionRef, admitReason = null, actorRank = null },
68
+ deps = {}
69
+ ) {
70
+ if (!deps.client) {
71
+ // Not a policy refusal — a programming error, and one whose consequence is a
72
+ // half-committed ratification. It throws rather than returning, because no
73
+ // caller can sensibly handle it and none should try.
74
+ throw new Error('createRatifiedGoal requires deps.client — it must run in the ratification transaction');
75
+ }
76
+ if (typeof title !== 'string' || !title.trim()) return refuse('TITLE_REQUIRED', 400);
77
+ if (typeof criterionMd !== 'string' || !criterionMd.trim()) return refuse('CRITERION_REQUIRED', 400);
78
+ if (typeof criterionRef !== 'string' || !criterionRef.trim()) return refuse('CRITERION_REF_REQUIRED', 400);
79
+
80
+ const scope = Array.isArray(scopeModules) ? scopeModules : [];
81
+ // Fail closed on an unknown module key, POST /goals' first gate: a scope wall
82
+ // that can name a module which does not exist is not a wall.
83
+ const unknown = scope.filter((k) => !isModuleKey(k));
84
+ if (unknown.length) return refuse('UNKNOWN_MODULE', 400, { unknown, valid: moduleKeys() });
85
+ // The two RANK gates. `actorRank` is the caller's LIVE rank read (ADR 0016) —
86
+ // never a value a request body carried — and an absent rank denies, because a
87
+ // rank we were not told is not a rank we may assume.
88
+ if (scopeIncludesProtected(scope) && actorRank !== 'archon') {
89
+ return refuse('ARCHON_REQUIRED_FOR_PROTECTED_SCOPE', 403, { scope_modules: scope });
90
+ }
91
+ const reason = typeof admitReason === 'string' ? admitReason.trim() || null : null;
92
+ // Refused by RANK before the version is fetched, the POST /goals ordering: a
93
+ // Metic must not learn whether the override would have worked.
94
+ if (reason !== null && actorRank !== 'archon') {
95
+ return refuse('ARCHON_REQUIRED_FOR_ADMISSION', 403);
96
+ }
97
+
98
+ // FOR UPDATE, so a version freeze committing between this read and this
99
+ // transaction's COMMIT cannot leave the goal and its criterion on a version
100
+ // that closed underneath them (getVersion's own note explains why a plain read
101
+ // is not enough under READ COMMITTED).
102
+ const version = await db.getVersion(versionId, { client: deps.client, forUpdate: true });
103
+ const decision = authorizeGoalCreate({ version, override: reason !== null });
104
+ if (!decision.ok) {
105
+ return refuse(decision.body.error, decision.status, { message: decision.body.message, version_status: decision.body.status });
106
+ }
107
+
108
+ const goal = await db.createGoal({
109
+ versionId,
110
+ title: title.trim(),
111
+ subtitle,
112
+ description,
113
+ scopeModules: scope,
114
+ createdBy,
115
+ // The admission row is written ONLY when the override actually opened the
116
+ // gate — `overrideApplied`, not "a reason was supplied". A reason on a
117
+ // planning version changed nothing and must not be filed as a skipped gate.
118
+ admission: decision.overrideApplied ? { admittedBy: createdBy, reason } : null,
119
+ }, { client: deps.client });
120
+
121
+ const criterion = await db.createCriterion({
122
+ versionId,
123
+ criterionId: criterionRef.trim(),
124
+ criterionMd,
125
+ goalId: goal.id,
126
+ }, { client: deps.client });
127
+
128
+ return { ok: true, goal, criterion, admitted: decision.overrideApplied };
129
+ }
130
+
131
+ module.exports = { createRatifiedGoal };
@@ -19,6 +19,7 @@
19
19
  const db = require('./db');
20
20
  const classifier = require('./task-classifier');
21
21
  const goalSuggest = require('./goal-suggest');
22
+ const goalFromIdea = require('./goal-from-idea');
22
23
  const helpRequests = require('./help-requests');
23
24
  const taskRecommendations = require('./task-recommendations');
24
25
  const activity = require('./activity');
@@ -66,6 +67,19 @@ module.exports = {
66
67
  // lifecycle tables and the ideas module may not import lifecycle (ADR 0083).
67
68
  ensureMaintenanceGoal: (...a) => db.ensureMaintenanceGoal(...a),
68
69
  currentBuildingVersionId: (...a) => db.currentBuildingVersionId(...a),
70
+ // createRatifiedGoal (task 1004096 / BV2.R17): the GOAL a ratified Full Idea
71
+ // becomes, plus the done-when criterion its "what does a successful
72
+ // implementation look like" answer supplies — both rows, in the ideas module's
73
+ // ratification transaction, or one named refusal.
74
+ //
75
+ // ONE CALL RATHER THAN FOUR PRIMITIVES, deliberately. The ideas module could
76
+ // have been handed getVersion + createGoal + createCriterion +
77
+ // authorizeGoalCreate and left to compose them, but composing them IS the
78
+ // goal-creation policy — the scope wall, the rank gates, the R03 building-
79
+ // version gate — and that policy is a lifecycle judgement. `idea.ratify`'s
80
+ // floor is Metic, so a caller free to leave the gate out would be a
81
+ // Metic-reachable way around it. See modules/lifecycle/goal-from-idea.js.
82
+ createRatifiedGoal: (...a) => goalFromIdea.createRatifiedGoal(...a),
69
83
 
70
84
  // --- work-tracking READS the staying-core read routes resolve --------------
71
85
  // public.js (the status surface): version/ship feed + grade trend.
@@ -7,7 +7,7 @@
7
7
  // that makes shipped work visible to users. It was reachable from exactly one
8
8
  // place — a shell on the control-plane box with an ssh key — so the owner could not
9
9
  // take it, and neither could an agent (the permission classifier blocks a mutating
10
- // `sudo -u lars` over ssh by design).
10
+ // `sudo -u <app user>` over ssh by design).
11
11
  //
12
12
  // THIS FILE ONLY ASKS. It validates and queues an intent; the control-plane runner
13
13
  // drains it and drives the proven `bongos upgrade` path with its health gate and
@@ -8,7 +8,7 @@
8
8
  // reachable from exactly one place — a shell on the control-plane box holding an
9
9
  // ssh key — which is why the owner said "I don't think I can make anything live",
10
10
  // and why an agent could not close the loop for them either: the permission
11
- // classifier blocks a mutating `sudo -u lars` over ssh by design.
11
+ // classifier blocks a mutating `sudo -u <app user>` over ssh by design.
12
12
  //
13
13
  // THE WEB TIER ONLY ASKS. This queues an intent; the control-plane runner drains it
14
14
  // and drives the proven `bongos upgrade` path with its health gate and auto-rollback
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.936",
3
+ "version": "1.19.938",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.936",
9
+ "version": "1.19.938",
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.936",
3
+ "version": "1.19.938",
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",
@@ -13,6 +13,8 @@
13
13
  # during a builder's session. (Use `req.builder.id` / the active claim.)
14
14
  # * `lars589` / `larskristensen` literals in setup-path code or test data
15
15
  # that would mis-attribute a second builder's work.
16
+ # * The founder's unix account in account position (`-o`/`-u <it>`,
17
+ # `User=<it>`, `APP_USER = '<it>'`) in runtime code — section 6.
16
18
  #
17
19
  # What is INTENTIONAL and allow-listed (NOT a violation):
18
20
  # * Archived shipping docs in `limitations/*-shipped.md` — historical record.
@@ -280,6 +282,36 @@ fi
280
282
  grep -vE "$OPS_ALLOWLIST_PATTERN" "$TMP_HITS" \
281
283
  | grep -vE "$SPEC_ALLOWLIST_PATTERN" > "$TMP_FILTERED" || true
282
284
 
285
+ # ----------------------------------------------------------------------------
286
+ # 6. The founder's UNIX ACCOUNT in account position (task 1002719). Family (c)
287
+ # greps the GitHub login, and a unix name is not a GitHub login — which is
288
+ # how the standup's `install -d -o <founder> …` reached every operator's box
289
+ # unseen. Runtime code only (tests/ pin fixtures by design), and the ops
290
+ # allowlist above does NOT apply: "that is the droplet's unix user" is the
291
+ # very assumption this family refuses in code that runs on other planes.
292
+ # Derive the account instead (provision-repo.js appUser()).
293
+ # ----------------------------------------------------------------------------
294
+ UNIX_USER_PATTERN='((^|[^A-Za-z0-9_.-])(-o|-g|-u)[ =]lars\b)|(\b(User|Group)=lars\b)|(\bchown( -R)? lars\b)|([A-Za-z_]*[Uu][Ss][Ee][Rr][A-Za-z_]*\s*[:=]\s*["'"'"']lars["'"'"'])'
295
+ UNIX_ALLOWLIST=(
296
+ # One-off backfill sweeps (tasks 1003939, 1003947), run by hand on the plane
297
+ # whose rows predate the fix; they name that plane's account on purpose.
298
+ 'scripts/gds/address-park-sweep.sh'
299
+ 'scripts/gds/stray-unit-sweep.sh'
300
+
301
+ # This script's own pattern.
302
+ 'scripts/gds/audit-authorship.sh'
303
+ )
304
+ UNIX_ALLOWLIST_RE=''
305
+ for e in "${UNIX_ALLOWLIST[@]}"; do
306
+ UNIX_ALLOWLIST_RE+="^${e//./\\.}:|"
307
+ done
308
+ UNIX_ALLOWLIST_RE="${UNIX_ALLOWLIST_RE%|}"
309
+
310
+ grep -RIn -E "${EXCLUDE_DIRS[@]}" --exclude-dir=tests --exclude-dir=test "${EXCLUDE_FILES[@]}" -e "$UNIX_USER_PATTERN" . 2>/dev/null \
311
+ | sed 's|^\./||' \
312
+ | sort -u \
313
+ | grep -vE "$UNIX_ALLOWLIST_RE" >> "$TMP_FILTERED" || true
314
+
283
315
  HIT_COUNT=$(wc -l < "$TMP_FILTERED" | tr -d ' ')
284
316
 
285
317
  if [ "$VERBOSE" = "1" ]; then
@@ -306,6 +338,7 @@ echo "Remediation:"
306
338
  echo " * Paths like /Users/larskristensen/... -> use \$HOME or os.path.expanduser('~')"
307
339
  echo " * builder_id = 1 / shipped_by = 1 -> use req.builder.id / the active claim"
308
340
  echo " * lars589 / larskristensen literals -> use the current builder's github_login"
341
+ echo " * -o/-u lars, User=lars, *user = 'lars' -> derive the account (appUser() in provision-repo.js)"
309
342
  echo ""
310
343
  echo "If a hit IS intentional (historical doc, droplet operational identity,"
311
344
  echo "or a frozen seed/import script), add it to the ALLOWLIST or"
@@ -69,7 +69,7 @@ DUMP_GLOB="${DB_NAME}-${D}${D}${D}${D}-${D}${D}-${D}${D}T${D}${D}${D}${D}${D}${D
69
69
  if [ ! -d "$BACKUP_DIR" ]; then
70
70
  echo "ERROR: backup dir $BACKUP_DIR does not exist." >&2
71
71
  echo " Create it with:" >&2
72
- echo " sudo install -d -o lars -g lars -m 0750 $BACKUP_DIR" >&2
72
+ echo " sudo install -d -o $(id -un) -g $(id -gn) -m 0750 $BACKUP_DIR" >&2
73
73
  exit 2
74
74
  fi
75
75
 
@@ -25,4 +25,14 @@ function provisionerBotEmail(env = process.env) {
25
25
  return apex ? `bot@${apex}` : 'bot@cloud-bongos.invalid';
26
26
  }
27
27
 
28
- module.exports = { instanceApex, provisionerBotEmail };
28
+ // The name that identity commits under: the pack's product name, else the platform's
29
+ // (task 1002719). Every caller builds `git config user.name "<name>"`, so a name the
30
+ // shell cannot carry inside double quotes falls back instead of being interpolated.
31
+ function brandProductName() {
32
+ try { return String(require('../../src/branding').branding().identity.productName || '').trim(); } catch { return ''; }
33
+ }
34
+ function provisionerBotName(name = brandProductName()) {
35
+ return name && !/["`$\\\r\n]/.test(name) ? name : 'Cloud Bongos';
36
+ }
37
+
38
+ module.exports = { instanceApex, provisionerBotEmail, provisionerBotName };
@@ -108,7 +108,7 @@ const CONFIG = {
108
108
  standaloneBase: process.env.PROVISION_STANDALONE_BASE || '/srv/cloudbongos',
109
109
  // ── Privilege escalation (task 2066, ADR 0128) ───────────────────────────
110
110
  // The intent runner runs as a NON-root user (provision-intent-runner.service:
111
- // User=lars) that holds passwordless sudo. That user cannot itself write under
111
+ // User= the app user) that holds passwordless sudo. That user cannot itself write under
112
112
  // /etc, reload systemd/Caddy, or peer-auth to Postgres as a superuser — so the
113
113
  // runbook steps below FAIL as bare commands (the goal-35 blocker). With
114
114
  // PROVISION_SUDO=1 (alias PROVISION_PRIVILEGED=1) the privileged steps escalate:
@@ -163,7 +163,7 @@ const CONFIG = {
163
163
 
164
164
  // task 1002757 — the provisioner's git identity resolves from the instance's own
165
165
  // apex (env → bot@<apex> → neutral), never the hub's address hardcoded.
166
- const { provisionerBotEmail } = require('./instance-apex');
166
+ const { provisionerBotEmail, provisionerBotName } = require('./instance-apex');
167
167
 
168
168
  const MAX_INTENT_ATTEMPTS = numEnv('PROVISION_INTENT_MAX_ATTEMPTS', 3);
169
169
 
@@ -229,5 +229,6 @@ module.exports = {
229
229
  numEnv,
230
230
  oauthSecret,
231
231
  provisionerBotEmail,
232
+ provisionerBotName,
232
233
  softFailResult,
233
234
  };
@@ -30,7 +30,7 @@
30
30
 
31
31
  const path = require('path');
32
32
  const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
33
- const { APP_USER, dbName, instanceUser, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
33
+ const { appUser, dbName, instanceUser, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
34
34
  const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
35
35
 
36
36
  // The shapes whose box this runner can actually reach. Mirrors UPGRADE_SHAPES in the
@@ -277,19 +277,19 @@ function parsePreflight(out) {
277
277
  * Which unix account should this instance's upgrade run as? Asks the box (task 1004128).
278
278
  *
279
279
  * The fleet is heterogeneous: instances provisioned before task 1003369 run as the
280
- * shared APP_USER, instances provisioned after run as their own `bongos-<slug>`. Neither
280
+ * shared app user, instances provisioned after run as their own `bongos-<slug>`. Neither
281
281
  * is guessable from the row, and getting it wrong is not a soft failure — `sudo` refuses
282
282
  * an unknown user and the upgrade dies before pre-flight, which is exactly how the
283
283
  * tenant leg has been broken.
284
284
  *
285
285
  * Falling back is NOT a privilege escalation: an instance with no account of its own is
286
- * already running as APP_USER (its unit says so), so this runs the upgrade as precisely
286
+ * already running as the app user (its unit says so), so this runs the upgrade as precisely
287
287
  * the account that owns the files and the database it is about to touch. It IS reported,
288
288
  * because a missing per-instance account is a provisioning gap someone should close, and
289
289
  * a silent fallback would hide it forever.
290
290
  */
291
291
  function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
292
- if (inst.hosting_shape === 'control-plane') return { runAs: APP_USER, fellBack: false };
292
+ if (inst.hosting_shape === 'control-plane') return { runAs: appUser(), fellBack: false };
293
293
  // Re-checked HERE rather than left to instanceUser()'s own throw, for the reason
294
294
  // upgradeInvocation states about itself: a call site whose safety depends on a
295
295
  // helper's internals is one refactor away from a shell-interpolation path.
@@ -317,7 +317,7 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
317
317
  if (!/no such user|unknown user/i.test(said)) {
318
318
  return { runAs: wanted, fellBack: false, probeInconclusive: true };
319
319
  }
320
- return { runAs: APP_USER, fellBack: true, wanted };
320
+ return { runAs: appUser(), fellBack: true, wanted };
321
321
  }
322
322
 
323
323
  // ONE BUILDER FOR BOTH LEGS (task 1004121).
@@ -357,7 +357,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
357
357
  // half right. The control-plane leg is correct and verified live. The tenant leg
358
358
  // assumed the per-instance account model of task 1003369 is provisioned everywhere —
359
359
  // and on the live box NO `bongos-*` account exists, no such Postgres role exists, and
360
- // every instance unit still says `User=lars`. So the fix for "Postgres has no role
360
+ // every instance unit still names the shared app user. So the fix for "Postgres has no role
361
361
  // root" swapped one absent account for another, and every tenant upgrade died with
362
362
  // `sudo: unknown user bongos-test4`.
363
363
  //
@@ -368,7 +368,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
368
368
  // (resolveRunAs below) instead of either side assuming. Passing it in keeps this
369
369
  // builder pure; the default preserves the old derivation for callers that have not
370
370
  // probed.
371
- const account = runAs || (inst.hosting_shape === 'control-plane' ? APP_USER : instanceUser(inst));
371
+ const account = runAs || (inst.hosting_shape === 'control-plane' ? appUser() : instanceUser(inst));
372
372
  const sudoP = privileged ? `sudo -u ${account} env PGDATABASE=${dbName(inst)} ` : '';
373
373
  const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
374
374
  const shared = [`${sudoP}node ${cli}`, '--registry', `--to ${to}`,
@@ -20,7 +20,7 @@
20
20
  const fs = require('node:fs');
21
21
  const path = require('node:path');
22
22
  const { CONFIG, REPO_ROOT } = require('./provision-config.js');
23
- const { APP_USER, CORE_PKG_DIR, instanceUser, standaloneRoot } = require('./provision-repo.js');
23
+ const { CORE_PKG_DIR, appGroupExpr, appUser, instanceUser, standaloneRoot } = require('./provision-repo.js');
24
24
  const {
25
25
  backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath,
26
26
  backupUnitName, serviceUnit, serviceUnitPath,
@@ -39,7 +39,7 @@ const systemd = {
39
39
  // The backup DIR belongs to this step, not to the runbook: it exists only to serve the
40
40
  // schedule, so a manager that installs no schedule must not create it either.
41
41
  installBackup(inst, { exec, writeFile, sudoP }) {
42
- exec(`${sudoP}install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}`);
42
+ exec(`${sudoP}install -d -o ${appUser()} -g ${appGroupExpr()} -m 0750 ${CONFIG.backupDir}`);
43
43
  writeFile(backupServicePath(inst), backupService(inst), { mode: 0o644, sudo: true });
44
44
  writeFile(backupTimerPath(inst), backupTimer(inst), { mode: 0o644, sudo: true });
45
45
  exec(`${sudoP}systemctl daemon-reload`);