@bongos/core 1.19.922 → 1.19.924

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.
@@ -0,0 +1,254 @@
1
+ // modules/ideas/idea-state.js — THE FIVE STATES (task 1004103 / BV2.R17,
2
+ // goal 1000095, criterion `ideator-sky-tells-the-truth`; spec
3
+ // docs/specs/bongos-v2-ideator-surface.md decisions 4, 19 and 20; the object
4
+ // vocabulary is docs/design/ideator-surface-direction.md §"The five objects";
5
+ // ADR 0322, under ADR 0321 + 0313).
6
+ //
7
+ // star / protostar / satellite / remnant / cold body. Every one of them is
8
+ // DERIVED here, from facts the ledger already holds, so that no surface has to
9
+ // infer one — which is the done-when. Three surfaces are about to draw these
10
+ // (the room, the descent, the sky) and a state decided three times is three
11
+ // states: the sky would say a thought went out while the room beside it still
12
+ // called it alive, with nothing erroring and no way to tell which was right.
13
+ //
14
+ // THE FACTS, AND WHERE EACH COMES FROM:
15
+ //
16
+ // idea_inbox.status 'open' | 'promoted' | 'discarded' | 'merged'
17
+ // — the triage verdict (core_195).
18
+ // brightness.hasWork has this thought been paid for anything at all
19
+ // brightness.share how much of that payout is still alive
20
+ // brightness.attributable whether the removal facts could be read
21
+ // — all three from economy's idea-brightness.js,
22
+ // reached through the `reward` port.
23
+ // tasks[].goal_id / .status the BINDING home and whether it awaits a nod
24
+ // — from the trail's lineage join.
25
+ //
26
+ // THE ORDER OF THE TESTS IS THE DESIGN, and each step below is a decision
27
+ // someone could reasonably have made the other way:
28
+ //
29
+ // 1. MERGED WINS OVER EVERYTHING — a merged thought is a satellite even when it
30
+ // earned money of its own. `modules/ideas/trail-size.js` is emphatic that a
31
+ // merged idea keeps its OWN size (stream 1 books against the idea whose
32
+ // lineage row named the shipping task), and that stays true: this changes
33
+ // what the thought IS, never what it earned. The design's word is "it orbits
34
+ // that one now" — once folded, it is no longer its own point in the sky, and
35
+ // drawing it as an independent star beside the thought that absorbed it would
36
+ // show one idea twice.
37
+ //
38
+ // 2. WORK BEATS A DISCARD. A discarded idea that nevertheless caused shipped,
39
+ // paid work is not a cold body — the design defines that object as "it never
40
+ // ignited, so it has no size at all", which is a stated PROPERTY, and testing
41
+ // work first is what keeps the property true by construction instead of by
42
+ // hope. This is reachable: triage can discard a duplicate whose twin already
43
+ // routed and paid, and `updateIdea` never touches the ledger.
44
+ //
45
+ // 3. REMNANT NEEDS EVIDENCE, NEVER ITS ABSENCE. `share === 0` says the project
46
+ // confirmed every piece of this descent gone. `share === null` says the
47
+ // removal facts could not be read at all (lifecycle off, port unregistered) —
48
+ // brightness is explicit that those are different answers, and treating the
49
+ // second as the first would draw every star in the instance as a hollow ring
50
+ // the moment a module failed to mount. An unreadable idea with work is a
51
+ // star, carrying `attributable: false` so a surface can say the brightness is
52
+ // unknown rather than full.
53
+ //
54
+ // 4. A PROTOSTAR IS THE ABSENCE OF WORK, not the presence of a status. Both an
55
+ // 'open' idea nobody has touched and a 'promoted' one whose task has not
56
+ // shipped yet are unignited, which is exactly right: ignition is being paid
57
+ // for something, not being converted into a row.
58
+ //
59
+ // POSITION IS A SEPARATE AXIS from the object, and it is the half the design
60
+ // says the object alone cannot carry: a protostar INSIDE A WELL has a home and
61
+ // awaits a nod; one in OPEN SPACE has nothing holding it. It is computed for
62
+ // every state (a star has a position too) but it is only load-bearing for a
63
+ // protostar, where it is the difference between "someone must act" and "nobody
64
+ // has claimed this".
65
+ //
66
+ // WHY `suggested_goal_id` IS NOT A WELL. It sits on the row, it names a goal,
67
+ // and it is the wrong fact: `projection.js` records that it is STRICTLY
68
+ // ADVISORY — the routing HINT on a filing that did NOT convert, with the binding
69
+ // home being `tasks.goal_id`. Reading it here would draw a protostar sitting
70
+ // inside a well that never caught it, and put it in a nod queue no one can act
71
+ // on because no task exists to promote.
72
+ //
73
+ // PURE. No DB, no clock, no port — the resolver takes facts and returns an
74
+ // answer, the way `routing.js` takes a filing and returns a landing. The one
75
+ // async function in the file is the trail attacher at the bottom, which fetches
76
+ // the brightness half and then calls the pure one. `tests/idea_state.mjs` drives
77
+ // every cell of the matrix DB-free because of that split.
78
+
79
+ const api = require('../../src/module-api');
80
+
81
+ // The five objects, spelled once. Exported so a renderer switches on the same
82
+ // vocabulary the resolver emits rather than a hand-copied list of strings that
83
+ // can drift by a hyphen.
84
+ const IDEA_STATES = Object.freeze({
85
+ STAR: 'star',
86
+ PROTOSTAR: 'protostar',
87
+ SATELLITE: 'satellite',
88
+ REMNANT: 'remnant',
89
+ COLD_BODY: 'cold-body',
90
+ });
91
+ const IDEA_STATE_VALUES = Object.freeze(Object.values(IDEA_STATES));
92
+
93
+ // The two positions. `null` is not a third one — it is "this thought has no
94
+ // task in any goal", which IS open space; the field is never null.
95
+ const IDEA_POSITIONS = Object.freeze({ WELL: 'well', OPEN_SPACE: 'open-space' });
96
+
97
+ // The task status that means "routed into a goal, one Metic+ promote from
98
+ // claimable" — the nod the landing matrix soft-lands every non-bug at
99
+ // (`routing.js` HARD_LAND_KINDS). Spelled here rather than imported because
100
+ // routing.js is about the DECISION to land and this is about reading the result
101
+ // of one; the coupling is the status vocabulary, which is core's.
102
+ const AWAITING_NOD_STATUS = 'backlog';
103
+
104
+ // A positive integer id as it arrives off a row, where a bigint may be a string.
105
+ // Never coerced with Number() — the guard economy's idea-size.js uses, for the
106
+ // same precision reason.
107
+ function idOf(v) {
108
+ return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
109
+ }
110
+
111
+ // taskFactsFromIdeaRow — the { goalId, status } pairs an idea's state depends
112
+ // on, taken off a trail row.
113
+ //
114
+ // It lives here, with the resolver that consumes it, for the reason economy's
115
+ // `descentRowsFromTrail` lives with the pricing rather than in the trail: a
116
+ // hand-built projection at the call site is a copy that can quietly stop
117
+ // carrying a field the consumer needs, and nothing errors when it does.
118
+ //
119
+ // BOTH SOURCES, because neither alone is complete. `lineage_tasks` is the full
120
+ // answer for anything promoted since `ideas_task_lineage` existed (one idea can
121
+ // become many tasks); `promoted_to_task_id` is the ONLY answer for promotions
122
+ // that predate it. The legacy row is added only when the lineage does not
123
+ // already name that task, so an idea carrying both is not counted twice.
124
+ function taskFactsFromIdeaRow(row) {
125
+ const out = [];
126
+ const seen = new Set();
127
+ const lineage = Array.isArray(row && row.lineage_tasks) ? row.lineage_tasks : [];
128
+ for (const t of lineage) {
129
+ if (!t) continue;
130
+ const taskId = idOf(t.task_id);
131
+ if (taskId) seen.add(taskId);
132
+ out.push({ taskId, goalId: idOf(t.goal_id), status: t.status || null });
133
+ }
134
+ const legacyId = idOf(row && row.promoted_to_task_id);
135
+ if (legacyId && !seen.has(legacyId)) {
136
+ out.push({
137
+ taskId: legacyId,
138
+ goalId: idOf(row.promoted_task_goal_id),
139
+ status: row.promoted_task_status || null,
140
+ });
141
+ }
142
+ return out;
143
+ }
144
+
145
+ // resolveIdeaState — the whole decision, pure.
146
+ //
147
+ // status the idea's triage status
148
+ // hasWork brightness.hasWork — has it been paid for anything
149
+ // share brightness.share — the live fraction, or null if unreadable
150
+ // attributable brightness.attributable — whether the removal facts were read
151
+ // tasks taskFactsFromIdeaRow(row)
152
+ //
153
+ // Returns { state, position, wellGoalId, awaitingNod, brightness, hasWork,
154
+ // attributable }
155
+ //
156
+ // `brightness` is passed straight through so a surface drawing the object and
157
+ // its dimness reads ONE answer: a renderer that took the state from here and the
158
+ // share from somewhere else could draw a full-brightness remnant.
159
+ //
160
+ // A MISSING BRIGHTNESS IS NOT ZERO WORK. When the caller has no brightness at
161
+ // all (the `reward` port is absent), `hasWork` arrives undefined and the idea
162
+ // resolves on its status alone — an unignited-looking protostar — with
163
+ // `attributable: false` saying the money could not be read. Defaulting `hasWork`
164
+ // to false would be the same claim; the flag is what stops a surface from
165
+ // stating it.
166
+ function resolveIdeaState({ status = null, hasWork = false, share = null, attributable = false, tasks = [] } = {}) {
167
+ const list = Array.isArray(tasks) ? tasks : [];
168
+ // A well is a goal that actually HOLDS one of this thought's tasks.
169
+ const inGoal = list.find((t) => t && t.goalId);
170
+ const wellGoalId = inGoal ? inGoal.goalId : null;
171
+ const position = wellGoalId ? IDEA_POSITIONS.WELL : IDEA_POSITIONS.OPEN_SPACE;
172
+ // The nod is a property of a TASK, so it is asked of every task in a goal and
173
+ // not only of the one that named the well: an idea that became two tasks, one
174
+ // promoted and one still at backlog, still has something awaiting a person.
175
+ const awaitingNod = list.some((t) => t && t.goalId && t.status === AWAITING_NOD_STATUS);
176
+
177
+ const common = {
178
+ position,
179
+ wellGoalId,
180
+ awaitingNod,
181
+ brightness: share,
182
+ hasWork: hasWork === true,
183
+ attributable: attributable === true,
184
+ };
185
+
186
+ // 1. Folded into another thought — whatever else is true of it.
187
+ if (status === 'merged') return { state: IDEA_STATES.SATELLITE, ...common };
188
+
189
+ // 2/3. It ignited. Only CONFIRMED removal of the whole descent puts it out.
190
+ if (hasWork === true) {
191
+ const dead = attributable === true && share === 0;
192
+ return { state: dead ? IDEA_STATES.REMNANT : IDEA_STATES.STAR, ...common };
193
+ }
194
+
195
+ // 4. Declined at triage, having caused nothing.
196
+ if (status === 'discarded') return { state: IDEA_STATES.COLD_BODY, ...common };
197
+
198
+ // 5. Filed, not yet ignited. `position` is the half that matters here.
199
+ return { state: IDEA_STATES.PROTOSTAR, ...common };
200
+ }
201
+
202
+ // attachStateToTrail — hangs `state` on every idea of a trail page, in place,
203
+ // mirroring attachDescentToTrail / attachSizeToTrail so the route composes the
204
+ // three the same way.
205
+ //
206
+ // COMPOSED AFTER attachSizeToTrail, and that ordering saves a whole second
207
+ // ledger read: size leaves `idea.size` carrying economy's own `byTask`
208
+ // breakdown, which is exactly the input brightness takes its share of. Handing
209
+ // it back means one query for the page rather than two. When an idea's size is
210
+ // missing or carries no breakdown — the port was absent, so size is null —
211
+ // brightness is asked for nothing and the ideas resolve on status alone.
212
+ //
213
+ // AN ABSENT PORT DOES NOT MAKE EVERY THOUGHT DARK. The state still resolves,
214
+ // carrying `attributable: false`; what it must never do is silently claim the
215
+ // sky is empty. Same posture trail-size.js takes with `size: null`.
216
+ async function attachStateToTrail(ideas, { client = null } = {}) {
217
+ const list = Array.isArray(ideas) ? ideas : [];
218
+ if (list.length === 0) return list;
219
+
220
+ const reward = api.resolveOptional && api.resolveOptional('reward');
221
+ let brightnesses = null;
222
+ if (reward && typeof reward.ideaBrightnesses === 'function') {
223
+ const sizes = new Map();
224
+ for (const idea of list) {
225
+ if (idea && idea.size && idea.size.byTask instanceof Map) sizes.set(String(idea.id), idea.size);
226
+ }
227
+ brightnesses = await reward.ideaBrightnesses(
228
+ list.map((i) => i.id),
229
+ { client, ...(sizes.size === list.length ? { sizes } : {}) }
230
+ );
231
+ }
232
+
233
+ for (const idea of list) {
234
+ const b = brightnesses && brightnesses.get(String(idea.id));
235
+ idea.state = resolveIdeaState({
236
+ status: idea.status,
237
+ hasWork: b ? b.hasWork : false,
238
+ share: b ? b.share : null,
239
+ attributable: b ? b.attributable : false,
240
+ tasks: taskFactsFromIdeaRow(idea),
241
+ });
242
+ }
243
+ return list;
244
+ }
245
+
246
+ module.exports = {
247
+ IDEA_STATES,
248
+ IDEA_STATE_VALUES,
249
+ IDEA_POSITIONS,
250
+ AWAITING_NOD_STATUS,
251
+ taskFactsFromIdeaRow,
252
+ resolveIdeaState,
253
+ attachStateToTrail,
254
+ };
@@ -43,6 +43,7 @@ const { attachDescentToTrail } = require('../trail-descent');
43
43
  // file for the same two reasons trail-descent.js is: inbox.js is at the size
44
44
  // ratchet, and a ledger read keyed by idea is a third relation again.
45
45
  const { attachSizeToTrail } = require('../trail-size');
46
+ const { attachStateToTrail } = require('../idea-state');
46
47
  // The walk itself, published as the `ideas.descent` port below.
47
48
  const descent = require('../descent');
48
49
  // Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
@@ -644,6 +645,13 @@ module.exports = function buildInboxRouter() {
644
645
  // the number, and the number is a ledger read that does not depend on the
645
646
  // walk having run. One query for the whole page, through the `reward` port.
646
647
  await attachSizeToTrail(ideas);
648
+ // THE STATE — which of the five objects each thought is (BV2.R17). Last of
649
+ // the three, because it is the only one that reads the other two: it takes
650
+ // its share off the `byTask` breakdown size just left on the row, so the
651
+ // page pays for one ledger read rather than two. Derived here and not in the
652
+ // renderer — three surfaces are about to draw these, and a state inferred
653
+ // three times is three states.
654
+ await attachStateToTrail(ideas);
647
655
  return res.json({ builder_id: builderId, ideas, total, limit, offset });
648
656
  }, { errorCode: 'idea_trail_failed' }));
649
657
 
@@ -42,6 +42,16 @@ const { skillFrictionHiddenClause } = require('./skill-friction');
42
42
  // first. Both are returned: the lineage array is the complete answer, the legacy
43
43
  // column is the only answer for promotions that predate the lineage table. The
44
44
  // LATERAL is bounded by the page window, so this stays one query, not an N+1.
45
+ //
46
+ // `goal_id` RIDES ALONG ON BOTH TASK JOINS (task 1004103 / BV2.R17) because it
47
+ // is what tells a protostar in a WELL from one in OPEN SPACE — the distinction
48
+ // the design makes position carry, and the reason the nod queue is a thing a
49
+ // person can see rather than a list someone must remember to open
50
+ // (docs/design/ideator-surface-direction.md). It is the BINDING home for "which
51
+ // goal"; `idea_inbox.suggested_goal_id`, already on the row, is a HINT about a
52
+ // filing that did not convert and is deliberately not what ./idea-state.js
53
+ // reads. Two nullable columns on joins the query already makes, so this costs
54
+ // no round trip.
45
55
  const IDEA_TRAIL_DEFAULT_LIMIT = 20;
46
56
  const IDEA_TRAIL_MAX_LIMIT = 100;
47
57
 
@@ -68,6 +78,7 @@ async function listIdeaTrailForBuilder(builderId, { limit = IDEA_TRAIL_DEFAULT_L
68
78
  `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
69
79
  pt.title AS promoted_task_title,
70
80
  pt.status AS promoted_task_status,
81
+ pt.goal_id AS promoted_task_goal_id,
71
82
  mi.title AS merged_into_idea_title,
72
83
  mi.status AS merged_into_idea_status,
73
84
  COALESCE(lin.tasks, '[]'::json) AS lineage_tasks
@@ -78,7 +89,8 @@ async function listIdeaTrailForBuilder(builderId, { limit = IDEA_TRAIL_DEFAULT_L
78
89
  LEFT JOIN LATERAL (
79
90
  SELECT json_agg(json_build_object(
80
91
  'task_id', l.task_id, 'origin', l.origin,
81
- 'title', lt.title, 'status', lt.status
92
+ 'title', lt.title, 'status', lt.status,
93
+ 'goal_id', lt.goal_id
82
94
  ) ORDER BY l.task_id) AS tasks
83
95
  FROM ideas_task_lineage l
84
96
  JOIN tasks lt ON lt.id = l.task_id
@@ -124,8 +124,8 @@ function coreUpgradeFault(inst, to, live = null) {
124
124
  * is. So a second request is a conflict the route names, including when the open
125
125
  * intent is itself a core-upgrade.
126
126
  */
127
- async function enqueueCoreUpgrade(provisioning, db, instanceId, to, requestedBy) {
128
- const { intent, created } = await provisioning.enqueueIntent(db, instanceId, 'core-upgrade', requestedBy, to);
127
+ async function enqueueCoreUpgrade(provisioning, db, instanceId, to, requestedBy, action = 'core-upgrade') {
128
+ const { intent, created } = await provisioning.enqueueIntent(db, instanceId, action, requestedBy, to);
129
129
  if (created) return { intent, created: true };
130
130
  return { intent, created: false, conflict: 'open_intent' };
131
131
  }
@@ -172,7 +172,13 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
172
172
  // core_upgrades ledger (ADR 0293 D5): a privileged action with an
173
173
  // unattributed row is not acceptable here.
174
174
  const actor = `api:archon:${req.builder.id}`;
175
- const r = await enqueueCoreUpgrade(provisioning, pool, id, to, actor);
175
+ // `dry_run: true` asks what the move WOULD do and moves nothing (task 1004121).
176
+ // Queued the same way as the move, through the same single-open-intent slot, so a
177
+ // preview cannot be requested on top of a move that is already running — and, just
178
+ // as deliberately, a preview occupies that slot only for as long as it takes.
179
+ const dryRun = req.body && req.body.dry_run === true;
180
+ const action = dryRun ? 'core-upgrade-preflight' : 'core-upgrade';
181
+ const r = await enqueueCoreUpgrade(provisioning, pool, id, to, actor, action);
176
182
  if (r.conflict) {
177
183
  return res.fail('intent_conflict', {
178
184
  status: 409,
@@ -181,9 +187,15 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
181
187
  });
182
188
  }
183
189
  await provisioning.recordEvent(pool, {
184
- instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'core-upgrade',
185
- detail: `core pin move to ${to} requested`, actor,
190
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: action,
191
+ detail: dryRun ? `pre-flight check for ${to} requested` : `core pin move to ${to} requested`, actor,
186
192
  }).catch(() => {});
193
+ if (dryRun) {
194
+ return res.json({
195
+ ok: true, queued: true, action, target_version: to,
196
+ message: `Checking what moving to ${to} would do. Nothing is changed by this — the answer appears here when the check finishes.`,
197
+ });
198
+ }
187
199
  // No duration is promised and no success is claimed: this says the request
188
200
  // was RECORDED, which is the only thing true at this moment (ADR 0190 §7).
189
201
  return res.json({
@@ -213,6 +225,7 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
213
225
  */
214
226
  function previewFromSnapshot(inst, snap, openIntent) {
215
227
  const served = (snap && snap.served_version) || null;
228
+ const pf = (snap && snap.preflight) || null;
216
229
  const available = (snap && Array.isArray(snap.available) ? snap.available : []);
217
230
  const recommended = (snap && snap.recommended) || null;
218
231
  // Only what a move could actually go TO: newer than what is running, and admitted by
@@ -230,6 +243,12 @@ function previewFromSnapshot(inst, snap, openIntent) {
230
243
  // Named so the UI cannot accidentally render "up to date" over a missing reading.
231
244
  reading: served ? 'ok' : 'no_answer',
232
245
  observed_at: (snap && snap.observed_at) || null,
246
+ // What the upgrade SAYS it would do, from the runner's dry run (task 1004121).
247
+ // Withheld unless it was taken against the move now on offer: a verdict about
248
+ // 1.19.920 tells the owner nothing true about pressing a button that says 1.19.921,
249
+ // and a stale pass is worse than no answer because it reads as a guarantee.
250
+ preflight: pf && recommended && pf.target === recommended ? pf : null,
251
+ preflight_at: pf && recommended && pf.target === recommended ? (snap.preflight_at || null) : null,
233
252
  open_intent: openIntent ? { action: openIntent.action, state: openIntent.state, target_version: openIntent.target_version || null } : null,
234
253
  };
235
254
  }
@@ -0,0 +1,53 @@
1
+ -- provisioning_020_core_upgrade_preflight.sql — what the upgrade would say if you pressed it
2
+ -- (task 1004121, ADR 0293 D4).
3
+ --
4
+ -- WHY THIS EXISTS. Step one of the deploy control's two-step was labelled "see what would
5
+ -- change" and made no network call at all: it flipped a local flag and redrew one fixed
6
+ -- sentence naming the two versions. Nothing was computed and no precondition was checked.
7
+ --
8
+ -- What that cost, on 2026-09-20: the owner pressed Upgrade four times across four
9
+ -- instances. Every press restarted a live box, failed in pre-flight and rolled back, and
10
+ -- the four failures were three DIFFERENT causes — a root Postgres role, an unresolvable
11
+ -- database name (both task 1004119) and an unclean working tree (task 1004122). Each one
12
+ -- is a pre-flight check, and `upgrade.js --dry-run` runs the whole pre-flight before it
13
+ -- returns. Every one of them was reportable without restarting anything.
14
+ --
15
+ -- So the runner now performs that dry run on its own tick, beside the version probe it
16
+ -- already takes, and caches the verdict here. Same division of labour as the columns
17
+ -- next door (provisioning_019): facts the web tier may not go and look for itself, taken
18
+ -- by the process that has a shell and read by the process that does not.
19
+ --
20
+ -- KEYED BY THE TARGET IT WAS RUN AGAINST. A verdict is only about one move. `target` is
21
+ -- stored with it so the control can tell a live answer from one taken against a version
22
+ -- that has since been superseded, rather than showing a stale pass over a new release.
23
+
24
+ BEGIN;
25
+
26
+ ALTER TABLE provisioning_core_version_snapshot
27
+ -- { target, ok, lines[], error } as the dry run reported it, or NULL when there was
28
+ -- nothing to preview (the box is current, so no move exists to check).
29
+ ADD COLUMN IF NOT EXISTS preflight jsonb,
30
+ -- When the dry run ran, which is NOT observed_at: the version probe is taken every
31
+ -- tick and this is only re-taken when there is a move to check. A control that
32
+ -- rendered one timestamp for both would age-stamp the verdict with the probe's clock.
33
+ ADD COLUMN IF NOT EXISTS preflight_at timestamptz;
34
+
35
+ -- The intent vocabulary gains the ASKING action. The JS list (INTENT_ACTIONS) and this
36
+ -- CHECK are two spellings of one vocabulary, and a widening that lands in only one of
37
+ -- them fails at insert time, in production, on the first real use — which is exactly what
38
+ -- tests/core_upgrade_door.mjs asserts against, and what it caught here.
39
+ --
40
+ -- The target_version CHECK from provisioning_018 is re-stated, not just left alone: it
41
+ -- read `(action = 'core-upgrade') = (target_version IS NOT NULL)`, which would REFUSE a
42
+ -- preflight intent for naming the version it is about to check.
43
+ ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
44
+ ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
45
+ CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach',
46
+ 'settings-apply', 'restart', 'disconnect', 'core-upgrade',
47
+ 'core-upgrade-preflight'));
48
+
49
+ ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_target_version_chk;
50
+ ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_target_version_chk
51
+ CHECK ((action IN ('core-upgrade', 'core-upgrade-preflight')) = (target_version IS NOT NULL));
52
+
53
+ COMMIT;
@@ -61,7 +61,7 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
61
61
  // 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
62
62
  // 'core-upgrade' (task 1003159, ADR 0293): move this instance's core pin — the owner's
63
63
  // deploy door. The web tier only QUEUES it; the control-plane runner performs it.
64
- const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade'];
64
+ const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight'];
65
65
  // Every state a teardown can be asked for — i.e. everything except the one
66
66
  // state where there is provably nothing left to release. DERIVED from
67
67
  // INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.922",
3
+ "version": "1.19.924",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.922",
9
+ "version": "1.19.924",
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.922",
3
+ "version": "1.19.924",
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",