@bongos/core 1.19.571 → 1.19.572

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.571",
6
- "core_contract": "1.19.571",
7
- "source_commit": "342ef455a76142077c47deaa4ef6db7b76139f1e",
5
+ "core_version": "1.19.572",
6
+ "core_contract": "1.19.572",
7
+ "source_commit": "31894c826b1cb16600f02046b2e17a09f78f3712",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-06T23:07:09.000Z",
9
+ "built_at": "2026-09-07T00:51:07.099Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 450,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2040,
14
+ "functional_verbatim": 2042,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2514,
20
- "tree_sha256": "c508f7935f6abe6b6bdf02bab8820c895a17b9c1d3c689fab2c0e78ad6ce985b",
19
+ "file_count": 2516,
20
+ "tree_sha256": "dc4746621684478bdfcc20dba9ccc094ed1722956378f38939e11ff3f83e35d5",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -392,7 +392,7 @@
392
392
  {
393
393
  "path": "clients/bongos-client/index.d.ts",
394
394
  "mode": "0000644",
395
- "sha256": "2b37686507b5ea701c19a1086352a2f0c84eae46510ef12b3a601c18812610c6"
395
+ "sha256": "adaa20c8ce6bee76dc289f10929014382aa89d7373f94ddafc84f4c87eeb0ed4"
396
396
  },
397
397
  {
398
398
  "path": "clients/bongos-client/index.mjs",
@@ -1812,12 +1812,12 @@
1812
1812
  {
1813
1813
  "path": "docs/api-reference.md",
1814
1814
  "mode": "0000644",
1815
- "sha256": "33d4d9b7530ebeebffc8c7200a137f5624b8319ff9a26e8b7b1e3209acb413ae"
1815
+ "sha256": "0a622847ab68980279beba2e1a54813b2393107144f88dd31228fe4dfd248b90"
1816
1816
  },
1817
1817
  {
1818
1818
  "path": "docs/api/openapi.json",
1819
1819
  "mode": "0000644",
1820
- "sha256": "9148616a367b2f7486a8f14c0f7ecf9ed367bd552075b15c4f2b0f3dd40712db"
1820
+ "sha256": "60ca0b131c661df16fb993182d2085d0b04ea15a4d91bec4318c3413fb8dc311"
1821
1821
  },
1822
1822
  {
1823
1823
  "path": "docs/architecture.md",
@@ -2692,7 +2692,7 @@
2692
2692
  {
2693
2693
  "path": "docs/module-api-changelog.md",
2694
2694
  "mode": "0000644",
2695
- "sha256": "64941aa45c872838ad9b962b98fd750eea92110cfdb9b40bb16b0c57748f09c3"
2695
+ "sha256": "d7acc0617ab16007150c0f733248576e5c285286f22b32ec722fa061748994f9"
2696
2696
  },
2697
2697
  {
2698
2698
  "path": "docs/modules-contract.md",
@@ -5467,7 +5467,7 @@
5467
5467
  {
5468
5468
  "path": "modules/lifecycle/db-goals.js",
5469
5469
  "mode": "0000644",
5470
- "sha256": "16c5f2ed9ae4b70a30eb47a4864c4ad6273afbe19e925edcac5f9d965e670f24"
5470
+ "sha256": "31f39ea088b03feb217af4ce2fbf0b4dba0d8dad3e8eec625cf11e0dd4cb4228"
5471
5471
  },
5472
5472
  {
5473
5473
  "path": "modules/lifecycle/db-grade.js",
@@ -5507,7 +5507,7 @@
5507
5507
  {
5508
5508
  "path": "modules/lifecycle/db.js",
5509
5509
  "mode": "0000644",
5510
- "sha256": "c6ab8ace534687d1989e4b192809a373294e539f7fb8624f512fda4ceea884ba"
5510
+ "sha256": "61ed58e2d0dbe9aa0b889d27b6a9c38df024a40bc797f22b0ff766fedf85a9de"
5511
5511
  },
5512
5512
  {
5513
5513
  "path": "modules/lifecycle/dead-deps.js",
@@ -5624,6 +5624,11 @@
5624
5624
  "mode": "0000644",
5625
5625
  "sha256": "0d17971d88c2f75a61a5646325ebab121fd4406cdd39324a1128a5e610ce6d7d"
5626
5626
  },
5627
+ {
5628
+ "path": "modules/lifecycle/migrations/lifecycle_008_goal_version_admissions.sql",
5629
+ "mode": "0000644",
5630
+ "sha256": "4e4099764712b6ac8ad322cb846eb1e4b1118e936d03628e01a6a04adb4d4ebf"
5631
+ },
5627
5632
  {
5628
5633
  "path": "modules/lifecycle/mingle.js",
5629
5634
  "mode": "0000644",
@@ -5682,12 +5687,12 @@
5682
5687
  {
5683
5688
  "path": "modules/lifecycle/routes/goal-route-authz.js",
5684
5689
  "mode": "0000644",
5685
- "sha256": "c380ad664c18beff53dd50e9bd5ad9ba78c05c18fae182cc0b0cc8ea41ee1a75"
5690
+ "sha256": "5022ec9cdd2ba5087bc0b4607691c21d361f4a4edbf029ff3dea37ade373e890"
5686
5691
  },
5687
5692
  {
5688
5693
  "path": "modules/lifecycle/routes/goals.js",
5689
5694
  "mode": "0000644",
5690
- "sha256": "9d3079d7f3f610aa5893f02fb4858f73ec98718d6096c423cd52c15d2b30d1ec"
5695
+ "sha256": "d408d89cf736a7d4cdf45ccff44c2038bbc64b13be42dd1b460198dfc8112535"
5691
5696
  },
5692
5697
  {
5693
5698
  "path": "modules/lifecycle/routes/help-requests.js",
@@ -5747,7 +5752,7 @@
5747
5752
  {
5748
5753
  "path": "modules/lifecycle/routes/versions.js",
5749
5754
  "mode": "0000644",
5750
- "sha256": "a825191c1ef406ec4c0eb9455df5b65c5ecf25e23d2b9a9f78eeeae424fa2bf6"
5755
+ "sha256": "5efe819220f9128844793a0c068babcd402860b434aab322fd9a99f8d2d26167"
5751
5756
  },
5752
5757
  {
5753
5758
  "path": "modules/lifecycle/routes/visuals.js",
@@ -7542,12 +7547,12 @@
7542
7547
  {
7543
7548
  "path": "package-lock.json",
7544
7549
  "mode": "0000644",
7545
- "sha256": "6e8b9fee606ca0b7f97205d2cd7eace56e341a525232e39e3da81e05390da456"
7550
+ "sha256": "439507cc695f5ef93f4fe1aa9a48556b4b9e11a023fa4e60912de1ed8befc5ee"
7546
7551
  },
7547
7552
  {
7548
7553
  "path": "package.json",
7549
7554
  "mode": "0000644",
7550
- "sha256": "5a2e1030453e82c8b52575c904e816e833343788d90e07c5d1946f15207fe8a4"
7555
+ "sha256": "bf5fe42d980095754ea4b13501ede3a559376dae4127a99915767ba4be0c27d2"
7551
7556
  },
7552
7557
  {
7553
7558
  "path": "public-docs/index.html",
@@ -9247,7 +9252,7 @@
9247
9252
  {
9248
9253
  "path": "src/module-api.js",
9249
9254
  "mode": "0000644",
9250
- "sha256": "a92eeea78188a9a1658c5d6dc928062720189bf23511e2ca50d9a4eac5ef91ef"
9255
+ "sha256": "be71a580784409ac5e4507201fbbf9915e16f0682098e3b9e98ec49a0af98216"
9251
9256
  },
9252
9257
  {
9253
9258
  "path": "src/module-loader/catalog.js",
@@ -10329,6 +10334,11 @@
10329
10334
  "mode": "0000644",
10330
10335
  "sha256": "3e157d5bf44370e4935b70203fe59e40cf9120d12c472f24ed7bd85cb33cbe91"
10331
10336
  },
10337
+ {
10338
+ "path": "tests/goal_admission_override.mjs",
10339
+ "mode": "0000644",
10340
+ "sha256": "94b378fb96bbbb6285b61c4d00827f57e0b29d64d9036576670ffaf8ca233239"
10341
+ },
10332
10342
  {
10333
10343
  "path": "tests/goal_advisory.mjs",
10334
10344
  "mode": "0000644",
@@ -10342,7 +10352,7 @@
10342
10352
  {
10343
10353
  "path": "tests/goal_category.mjs",
10344
10354
  "mode": "0000644",
10345
- "sha256": "05d46d4dcfd3a3b21ef0fc258263081cf4e624bdef4348daf84a90da589387c3"
10355
+ "sha256": "dbdfd351d5edc3ac6adcb1a34e072fab0a8db9669030d7476bbf18aa1e81540d"
10346
10356
  },
10347
10357
  {
10348
10358
  "path": "tests/goal_category_ui.mjs",
@@ -10367,7 +10377,7 @@
10367
10377
  {
10368
10378
  "path": "tests/goal_create_version_gate.mjs",
10369
10379
  "mode": "0000644",
10370
- "sha256": "addd9fe7179236d24c316923fd47a60947c1a34f9e01eb1a4d47faa49b4fa18f"
10380
+ "sha256": "28da055ccad13b327135dce6292df9c6bc53e2589648e0a4361ada0ddd21b863"
10371
10381
  },
10372
10382
  {
10373
10383
  "path": "tests/goal_criterion_confirm_ui.mjs",
@@ -10422,7 +10432,7 @@
10422
10432
  {
10423
10433
  "path": "tests/goal_tier.mjs",
10424
10434
  "mode": "0000644",
10425
- "sha256": "7d8c1b8300c99be22b42ff25e8ce25e25b5af28cd29c314f9aee7ddee11fc0d5"
10435
+ "sha256": "3c420d28341a6e5aaeb5d4af978e72acefec3c9bd7d97f66fd11a72517118606"
10426
10436
  },
10427
10437
  {
10428
10438
  "path": "tests/goal_wall_proof.mjs",
@@ -49,7 +49,7 @@ export interface GetGithubRepoResponse { ok: boolean; repo: unknown; private_dep
49
49
  export interface GetGithubReposResponse { repos: unknown }
50
50
  export interface GetGoalsIdConflictsResponse { ok: boolean; goal_id: unknown; conflicts: unknown; cross_goals_considered: unknown }
51
51
  export interface GetGoalsIdRequestsResponse { requests: unknown }
52
- export interface GetGoalsIdResponse { goal: unknown; members: unknown; criteria: unknown; dependencies: unknown; criterionless_task_count: unknown }
52
+ export interface GetGoalsIdResponse { goal: unknown; members: unknown; criteria: unknown; dependencies: unknown; criterionless_task_count: unknown; admitted: unknown }
53
53
  export interface GetGoalsResponse { goals: unknown }
54
54
  export interface GetGoalsRollupsResponse { rollups: unknown }
55
55
  export interface GetGovernmentAssignmentsResponse { assignments: unknown }
@@ -278,8 +278,8 @@ export interface PostGoalsIdTransferRequest { builder_id?: StringifiableId }
278
278
  export interface PostGoalsIdTransferResponse { ok: boolean; goal: unknown; changed: boolean }
279
279
  export interface PostGoalsReorderRequest { version_id: string; ordered_ids: number[] }
280
280
  export interface PostGoalsReorderResponse { ok: boolean; goals: unknown }
281
- export interface PostGoalsRequest { version_id: string; title: string; subtitle?: string; description?: string; scope_modules?: string[]; succeeds_goal_id?: number; category_id?: number }
282
- export interface PostGoalsResponse { ok: boolean; goal: unknown; succeeds_goal_id: unknown; category_advisory: unknown }
281
+ export interface PostGoalsRequest { version_id: string; title: string; subtitle?: string; description?: string; scope_modules?: string[]; succeeds_goal_id?: number; category_id?: number; admit_reason?: string }
282
+ export interface PostGoalsResponse { ok: boolean; goal: unknown; succeeds_goal_id: unknown; category_advisory: unknown; admitted: unknown }
283
283
  export interface PostGovernmentAssignmentsRequest { builder_id: number; rank_key: string }
284
284
  export interface PostGovernmentBoardItemsItemIdVotesRequest { direction: string; reason_md?: string; section_key?: string }
285
285
  export interface PostGovernmentBoardItemsRequest { subject_type: string; proposal: Record<string, unknown>; rationale_md?: string; acknowledge_self_removal?: boolean }
@@ -16955,14 +16955,16 @@
16955
16955
  "members": {},
16956
16956
  "criteria": {},
16957
16957
  "dependencies": {},
16958
- "criterionless_task_count": {}
16958
+ "criterionless_task_count": {},
16959
+ "admitted": {}
16959
16960
  },
16960
16961
  "required": [
16961
16962
  "goal",
16962
16963
  "members",
16963
16964
  "criteria",
16964
16965
  "dependencies",
16965
- "criterionless_task_count"
16966
+ "criterionless_task_count",
16967
+ "admitted"
16966
16968
  ]
16967
16969
  },
16968
16970
  "GetGoalsResponse": {
@@ -20325,6 +20327,11 @@
20325
20327
  "category_id": {
20326
20328
  "type": "integer",
20327
20329
  "minimum": 1
20330
+ },
20331
+ "admit_reason": {
20332
+ "type": "string",
20333
+ "minLength": 8,
20334
+ "maxLength": 500
20328
20335
  }
20329
20336
  },
20330
20337
  "required": [
@@ -20341,13 +20348,15 @@
20341
20348
  },
20342
20349
  "goal": {},
20343
20350
  "succeeds_goal_id": {},
20344
- "category_advisory": {}
20351
+ "category_advisory": {},
20352
+ "admitted": {}
20345
20353
  },
20346
20354
  "required": [
20347
20355
  "ok",
20348
20356
  "goal",
20349
20357
  "succeeds_goal_id",
20350
- "category_advisory"
20358
+ "category_advisory",
20359
+ "admitted"
20351
20360
  ]
20352
20361
  },
20353
20362
  "PostGovernmentAssignmentsRequest": {
@@ -226,7 +226,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
226
226
  | Method | Path | Rank | Body | Description |
227
227
  |---|---|---|---|---|
228
228
  | GET | `/api/bongos/goals` | `any-builder` | — | GET /goals?version=&status= — list goals (read). |
229
- | POST | `/api/bongos/goals` | `metic+archon` | `version_id`, `title`, `subtitle`, `description`, `scope_modules`, `succeeds_goal_id`, `category_id` | POST /goals — create a goal. |
229
+ | POST | `/api/bongos/goals` | `metic+archon` | `version_id`, `title`, `subtitle`, `description`, `scope_modules`, `succeeds_goal_id`, `category_id`, `admit_reason` | POST /goals — create a goal. |
230
230
  | GET | `/api/bongos/goals/:id` | `any-builder` | — | GET /goals/:id — one goal with its members + linked criteria (read). |
231
231
  | PATCH | `/api/bongos/goals/:id` | `any-builder` | — | PATCH /goals/:id — correct a goal's own title/subtitle/description (tasks 1003102, 1003224). |
232
232
  | PATCH | `/api/bongos/goals/:id/accepting-requests` | `any-builder` | — | PATCH /goals/:id/accepting-requests { accepting_requests } — a private goal's lead closes or re-opens it to join-requests (task 1002971, … |
@@ -1591,5 +1591,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1591
1591
  landed since 1.19.569 with no explicit bump. run 34060442118. (task 1002620)
1592
1592
  1.19.571 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1593
1593
  landed since 1.19.570 with no explicit bump. run 34065943703. (task 1002620)
1594
+ 1.19.572 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1595
+ landed since 1.19.571 with no explicit bump. run 34071078624. (task 1002620)
1594
1596
  ---------------------------------------------------------------------------
1595
1597
  ```
@@ -49,8 +49,15 @@ const MEMBERSHIP_REQUEST_COLS =
49
49
  // atomically. createdBy NULL (system-attributed, e.g. the migration-160 default
50
50
  // goals) inserts no member — those goals have no owner; Archons act as one.
51
51
 
52
+ // `admission` (BV1.R07, task 1003594) is the R03 escape valve's record: when an
53
+ // Archon admits this goal into a BUILDING version, the who/why row rides in the
54
+ // SAME statement as the goal insert. Atomic on purpose — a goal that skipped the
55
+ // scope gate with no surviving record of who waved it through is precisely the
56
+ // state R03 exists to prevent, and a second round-trip can fail after the goal
57
+ // lands. Pass null (the normal path) and no admission row is written at all, so
58
+ // the presence of a row always means the gate was actually overridden.
52
59
  async function createGoal(
53
- { versionId, title, subtitle = null, description = null, scopeModules = [], status = 'open', createdBy = null, categoryId = null },
60
+ { versionId, title, subtitle = null, description = null, scopeModules = [], status = 'open', createdBy = null, categoryId = null, admission = null },
54
61
  deps = {}
55
62
  ) {
56
63
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
@@ -64,13 +71,49 @@ async function createGoal(
64
71
  ), first_lead AS (
65
72
  INSERT INTO goal_members (goal_id, builder_id, membership_kind)
66
73
  SELECT g.id, $6::bigint, 'lead' FROM g WHERE $6::bigint IS NOT NULL
74
+ ), admitted AS (
75
+ INSERT INTO lifecycle_goal_version_admissions (goal_id, version_id, admitted_by, reason)
76
+ SELECT g.id, g.version_id, $9::bigint, $10::text FROM g WHERE $9::bigint IS NOT NULL
67
77
  )
68
78
  SELECT * FROM g`,
69
- [versionId, title, description, scopeModules, status, createdBy, subtitle, categoryId]
79
+ [versionId, title, description, scopeModules, status, createdBy, subtitle, categoryId,
80
+ admission ? admission.admittedBy : null, admission ? admission.reason : null]
70
81
  );
71
82
  return rows[0];
72
83
  }
73
84
 
85
+ // listVersionAdmissions (BV1.R07, task 1003594) — every goal admitted into ONE
86
+ // version, newest first. ADR 0250 §3: "the overrides are counted and shown on the
87
+ // version" — a visible counter is what keeps the exception honest, because an
88
+ // override nobody can see is just a slower version of having no rule. Returns []
89
+ // for the overwhelmingly common case of a version whose scope nobody widened.
90
+ async function listVersionAdmissions(versionId, deps = {}) {
91
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
92
+ const { rows } = await activePool.query(
93
+ `SELECT a.goal_id, a.version_id, a.admitted_by, a.reason, a.created_at, g.title AS goal_title
94
+ FROM lifecycle_goal_version_admissions a
95
+ JOIN goals g ON g.id = a.goal_id
96
+ WHERE a.version_id = $1
97
+ ORDER BY a.created_at DESC`,
98
+ [versionId]
99
+ );
100
+ return rows;
101
+ }
102
+
103
+ // getGoalAdmission (BV1.R07, task 1003594) — the override record for ONE goal, or
104
+ // null when the goal was created normally. Null is the overwhelmingly common
105
+ // answer and is not an absence of data: it is the positive fact "this goal did
106
+ // not skip the scope gate".
107
+ async function getGoalAdmission(goalId, deps = {}) {
108
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
109
+ const { rows } = await activePool.query(
110
+ `SELECT goal_id, version_id, admitted_by, reason, created_at
111
+ FROM lifecycle_goal_version_admissions WHERE goal_id = $1`,
112
+ [goalId]
113
+ );
114
+ return rows[0] ?? null;
115
+ }
116
+
74
117
 
75
118
  async function getGoal(id, deps = {}) {
76
119
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
@@ -807,6 +850,7 @@ module.exports = {
807
850
  createGoal,
808
851
  createMembershipRequest,
809
852
  getGoal,
853
+ getGoalAdmission,
810
854
  getGoalDependencies,
811
855
  getGoalMember,
812
856
  getMembershipRequest,
@@ -818,6 +862,7 @@ module.exports = {
818
862
  listGoals,
819
863
  listInboxFor,
820
864
  listPendingRequestsForGoal,
865
+ listVersionAdmissions,
821
866
  removeGoalMember,
822
867
  reorderGoals,
823
868
  requestClaimRelease,
@@ -72,6 +72,8 @@ const {
72
72
  createGoal,
73
73
  createMembershipRequest,
74
74
  getGoal,
75
+ getGoalAdmission,
76
+ listVersionAdmissions,
75
77
  getGoalDependencies,
76
78
  getGoalMember,
77
79
  getMembershipRequest,
@@ -217,6 +219,8 @@ module.exports = {
217
219
  createVersion,
218
220
  createGoal,
219
221
  getGoal,
222
+ getGoalAdmission,
223
+ listVersionAdmissions,
220
224
  getGoalDependencies,
221
225
  listGoals,
222
226
  reorderGoals,
@@ -0,0 +1,51 @@
1
+ -- lifecycle_008_goal_version_admissions.sql — the R07 escape valve for R03
2
+ -- (task 1003594, goal 1000086, ADR 0250 D1).
3
+ --
4
+ -- WHY THIS TABLE EXISTS. BV1.R03 (task 1003591) closed a version's goal set the
5
+ -- moment the version starts building: new goals belong on the `planning` version.
6
+ -- That rule is right and it is also absolute, which is a problem — an instance
7
+ -- with no planning version cannot create a goal at all, and a genuinely urgent
8
+ -- goal has nowhere to go. R07 is the valve: an Archon may admit ONE named goal
9
+ -- into a building version and must say why.
10
+ --
11
+ -- PER-GOAL, NEVER A BLANKET UNLOCK. `goal_id` is the PRIMARY KEY, so an admission
12
+ -- is a fact about one goal and cannot be widened into "this version is open
13
+ -- again". There is deliberately no version-level switch here — a row per decision
14
+ -- is what makes the scope growth auditable after the fact, which is the whole
15
+ -- reason R03 exists (BONGOS-V1 reached 64 goals with nobody deciding to widen it).
16
+ --
17
+ -- THE ROW IS THE RECORD. reason is NOT NULL because an override with no stated
18
+ -- cause is indistinguishable from the rule not existing; six weeks later the only
19
+ -- question anyone asks is "why is this goal here", and the answer has to be in
20
+ -- the row. admitted_by is the actor (an Archon — enforced at the route, since
21
+ -- rank is a live DB read and pinning it here would freeze it at write time), and
22
+ -- created_at is when. Together: the goal, the actor, the reason, the timestamp.
23
+ --
24
+ -- WRITTEN ONLY WHEN THE OVERRIDE ACTUALLY DID WORK — i.e. the version was
25
+ -- `building`. A goal created normally on a planning version has no row, so the
26
+ -- presence of a row always means "this goal skipped the scope gate", never
27
+ -- "someone passed a flag that changed nothing".
28
+ --
29
+ -- ON DELETE CASCADE: the admission is a fact ABOUT the goal and is meaningless
30
+ -- without it. A deleted goal's override record is not history worth orphaning.
31
+ --
32
+ -- MODULE-OWNED and `lifecycle_`-prefixed so fitness check 11 permits it (ADR 0083
33
+ -- §Decision #5) — the lifecycle_004..007 convention. It only REFERENCES the core
34
+ -- `goals` / `builders` tables; a module migration may not ALTER them.
35
+
36
+ CREATE TABLE IF NOT EXISTS lifecycle_goal_version_admissions (
37
+ goal_id bigint PRIMARY KEY REFERENCES goals(id) ON DELETE CASCADE,
38
+
39
+ -- Denormalised from goals.version_id ON PURPOSE: the question this table
40
+ -- answers is "which versions had their scope widened, and why", and a goal can
41
+ -- be carried forward to another version later. The admission belongs to the
42
+ -- version that was building AT THE TIME.
43
+ version_id text NOT NULL REFERENCES versions(id),
44
+
45
+ admitted_by bigint NOT NULL REFERENCES builders(id),
46
+ reason text NOT NULL,
47
+ created_at timestamptz NOT NULL DEFAULT now()
48
+ );
49
+
50
+ CREATE INDEX IF NOT EXISTS idx_lifecycle_goal_admissions_version
51
+ ON lifecycle_goal_version_admissions (version_id);
@@ -120,7 +120,13 @@ function authorizeGoalCreate({ version, override = false }) {
120
120
  message: `Version '${version.id}' is already building — its goal set is closed. Scope this goal onto the planning version instead, or ask an Archon to admit it with a reason.`,
121
121
  } };
122
122
  }
123
- return { ok: true };
123
+ // `overrideApplied` says whether the override DID WORK, not whether one was
124
+ // offered (BV1.R07, task 1003594). An admit_reason on a planning version is a
125
+ // no-op — the create was always going to succeed — and recording it would file
126
+ // a "this goal skipped the scope gate" row against a goal that skipped nothing.
127
+ // It is returned from here rather than recomputed at the route because this is
128
+ // the ONE place allowed to reason about version.status; the route renders.
129
+ return { ok: true, overrideApplied: override === true && version.status !== 'planning' };
124
130
  }
125
131
  function authorizeGoalTaskCreate({ goal, version, isMember, builderRank, touches }) {
126
132
  if (!goal) {
@@ -82,6 +82,11 @@ module.exports = function buildGoalsRouter() {
82
82
  // task 1003275 — the human category. ADVISORY: absent is legal (it becomes a
83
83
  // prompt, never a refusal), and no gate below reads it.
84
84
  category_id: { type: 'integer', min: 1 },
85
+ // BV1.R07 (task 1003594) — the Archon admission. Its PRESENCE is the
86
+ // override; there is no boolean beside it, so an override cannot be
87
+ // requested without stating a cause. minLength 8 because 'ok' / '.' is not
88
+ // a reason, and the row is the only place the why survives.
89
+ admit_reason: { type: 'string', minLength: 8, maxLength: 500 },
85
90
  })) return;
86
91
 
87
92
  const scopeModules = req.body.scope_modules || [];
@@ -96,6 +101,16 @@ module.exports = function buildGoalsRouter() {
96
101
  if (scopeIncludesProtected(scopeModules) && req.builder.rank !== 'archon') {
97
102
  return res.fail('archon_required_for_protected_scope', { status: 403, message: 'A goal scoped to a protected module can only be created by an Archon.' });
98
103
  }
104
+ // BV1.R07 (task 1003594, goal 1000086): admitting a goal into a building
105
+ // version is an Archon decision, so the field is refused by RANK before the
106
+ // version is even fetched — a Metic must not learn whether the override
107
+ // would have worked. Checked here rather than folded into the pure gate
108
+ // because rank is a live DB read on req.builder (ADR 0016), never a value
109
+ // the caller supplies.
110
+ const admitReason = (req.body.admit_reason || '').trim() || null;
111
+ if (admitReason !== null && req.builder.rank !== 'archon') {
112
+ return res.fail('archon_required_for_admission', { status: 403, message: 'Only an Archon may admit a goal into a version that is already building.' });
113
+ }
99
114
 
100
115
  try {
101
116
  const version = await db.getVersion(req.body.version_id);
@@ -103,7 +118,7 @@ module.exports = function buildGoalsRouter() {
103
118
  // fetched and then discarded after the null check — its status was never
104
119
  // read, so a goal could be added to a version that had been building for
105
120
  // months. The ordered, DB-free gate answers all of it, including the 404.
106
- const versionDecision = authorizeGoalCreate({ version });
121
+ const versionDecision = authorizeGoalCreate({ version, override: admitReason !== null });
107
122
  if (!versionDecision.ok) {
108
123
  return res.status(versionDecision.status).json(versionDecision.body);
109
124
  }
@@ -123,6 +138,14 @@ module.exports = function buildGoalsRouter() {
123
138
  if (categoryId !== null && !(await goalCategory.getCategory(categoryId))) {
124
139
  return res.fail('category_not_found', 404, { message: 'No such category. List them at GET /work-categories, or create one with POST /work-categories.' });
125
140
  }
141
+ // The admission row is written ONLY when the gate says the override
142
+ // actually opened it (see authorizeGoalCreate) — an admit_reason on a
143
+ // planning version changed nothing and must not be recorded as a skipped
144
+ // gate. It rides inside createGoal's statement so the goal and its
145
+ // justification land together or not at all.
146
+ const admission = versionDecision.overrideApplied
147
+ ? { admittedBy: req.builder.id, reason: admitReason }
148
+ : null;
126
149
  const goal = await db.createGoal({
127
150
  versionId: req.body.version_id,
128
151
  title: req.body.title,
@@ -130,6 +153,7 @@ module.exports = function buildGoalsRouter() {
130
153
  scopeModules,
131
154
  createdBy: req.builder.id,
132
155
  categoryId,
156
+ admission,
133
157
  });
134
158
  // Stamp the predecessor's succeeded_by pointer at the new goal (the lineage).
135
159
  if (succeedsId != null) {
@@ -144,7 +168,11 @@ module.exports = function buildGoalsRouter() {
144
168
  category_advisory = goalCategory.adviseCategory({ categoryId, categories: await goalCategory.listCategories() });
145
169
  } catch (_) { /* fail-open — advice only */ }
146
170
  }
147
- return res.status(201).json({ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory });
171
+ // `admitted` is null on every normal create. Non-null says the goal was
172
+ // waved into a building version, by whom and why — the caller should not
173
+ // have to re-read the goal to find out whether their override took effect.
174
+ const admitted = admission ? await db.getGoalAdmission(goal.id) : null;
175
+ return res.status(201).json({ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory, admitted });
148
176
  } catch (err) {
149
177
  log.error('[gds] POST /goals', err);
150
178
  return res.fail('goal_create_failed', { status: 500, message: 'internal error' });
@@ -257,7 +285,7 @@ module.exports = function buildGoalsRouter() {
257
285
  try {
258
286
  const goal = await db.getGoal(id);
259
287
  if (!goal) return res.fail('goal_not_found', 404);
260
- const [members, criteria, dependencies, criterionless_task_count] = await Promise.all([
288
+ const [members, criteria, dependencies, criterionless_task_count, admitted] = await Promise.all([
261
289
  db.listGoalMembers(id),
262
290
  db.listCriteriaForGoal(id),
263
291
  db.getGoalDependencies(id), // task 1761 — prerequisite goals + satisfied state
@@ -265,11 +293,16 @@ module.exports = function buildGoalsRouter() {
265
293
  // page already renders, so a builder sees the gap without waiting for
266
294
  // the create-time advisory to catch the next task.
267
295
  db.countTasksWithoutCriterionLink(id),
296
+ // BV1.R07 (task 1003594): null on every normal goal. Non-null is the
297
+ // record that this goal was admitted into a building version — the
298
+ // override is worthless as an audit trail if reading the goal never
299
+ // shows it.
300
+ db.getGoalAdmission(id),
268
301
  ]);
269
302
  const byId = await goalCategory.categoryIndex();
270
303
  return res.json({
271
304
  goal: goalCategory.decorateGoalCategory(decorateGoalScope(goal), byId),
272
- members, criteria, dependencies, criterionless_task_count,
305
+ members, criteria, dependencies, criterionless_task_count, admitted,
273
306
  });
274
307
  } catch (err) {
275
308
  log.error('[gds] GET /goals/:id', err);
@@ -76,9 +76,17 @@ module.exports = function buildVersionsRouter() {
76
76
  router.get('/versions/:id/progress', asyncHandler('GET /versions/:id/progress', async (req, res) => {
77
77
  const versionId = String(req.params.id || '').trim();
78
78
  if (!versionId) return res.fail('version_id_required', 400);
79
- const progress = await doneWhen.criterionProgress(versionId);
79
+ // BV1.R07 (task 1003594) — ADR 0250 §3: "the overrides are counted and shown
80
+ // on the version". The scope gate's exceptions ride the aggregate the status
81
+ // dashboard already reads, rather than a route nobody would think to call.
82
+ // `admissions` is [] for a version whose scope nobody widened, which is the
83
+ // answer for every version that has never needed the valve.
84
+ const [progress, admissions] = await Promise.all([
85
+ doneWhen.criterionProgress(versionId),
86
+ db.listVersionAdmissions(versionId),
87
+ ]);
80
88
  res.set('Cache-Control', 'public, max-age=60');
81
- res.json(progress);
89
+ res.json({ ...progress, admissions, admission_count: admissions.length });
82
90
  }, { message: false, errorCode: 'progress_failed' }));
83
91
 
84
92
  // POST /versions — create a version + its done-when criteria in one call. The
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.571",
3
+ "version": "1.19.572",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.571",
9
+ "version": "1.19.572",
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.571",
3
+ "version": "1.19.572",
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",
package/src/module-api.js CHANGED
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
49
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
50
50
  // the entry to that file. Look for a version's history there, not here.
51
51
  // ---------------------------------------------------------------------------
52
- const CORE_VERSION = '1.19.571'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.572'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
53
53
 
54
54
  // A namespaced logger so a module's log lines are attributable + consistent.
55
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,179 @@
1
+ // tests/goal_admission_override.mjs — the R03 escape valve: an Archon admits ONE
2
+ // named goal into a building version, and must say why (BV1.R07, task 1003594,
3
+ // goal 1000086, ADR 0250 D1).
4
+ //
5
+ // WHAT R03 LEFT BEHIND. BV1.R03 closed a version's goal set the moment the
6
+ // version starts building. Correct, and absolute: an instance whose only versions
7
+ // are `building` cannot create a goal at all — which is exactly the state
8
+ // cloudbongos.com was in when this task was claimed. R07 is the valve, and the
9
+ // thing that makes it a valve rather than a hole is that every use leaves a row
10
+ // naming the goal, the actor, the reason and the time.
11
+ //
12
+ // The gate stays pure and DB-free (the authorizeGoalTaskCreate precedent), so the
13
+ // decision half is exercised here without a database. The route half is pinned by
14
+ // reading the source — the same technique goal_create_version_gate.mjs uses — so
15
+ // the wiring cannot silently come undone.
16
+ //
17
+ // Run: node --test tests/goal_admission_override.mjs
18
+
19
+ import assert from 'node:assert/strict';
20
+ import { test } from 'node:test';
21
+ import { readFileSync } from 'node:fs';
22
+ import { createRequire } from 'node:module';
23
+
24
+ process.env.NODE_ENV = 'test';
25
+ const require = createRequire(import.meta.url);
26
+ const { authorizeGoalCreate } = require('../modules/lifecycle/routes/goal-route-authz.js');
27
+
28
+ const V = (status) => ({ id: 'BONGOS-V1', status });
29
+ const decide = (version, override) => authorizeGoalCreate({ version, override });
30
+
31
+ const routeSrc = () => readFileSync(new URL('../modules/lifecycle/routes/goals.js', import.meta.url), 'utf8');
32
+ const postGoals = () => {
33
+ const src = routeSrc();
34
+ const start = src.indexOf("router.post('/goals'");
35
+ assert.notEqual(start, -1, 'POST /goals must exist');
36
+ return src.slice(start, src.indexOf('router.', start + 20));
37
+ };
38
+
39
+ // ---- the valve opens, and says that it opened ------------------------------
40
+
41
+ test('an override admits a building version', () => {
42
+ assert.equal(decide(V('building'), true).ok, true);
43
+ });
44
+
45
+ test('overrideApplied distinguishes "it worked" from "it was offered"', () => {
46
+ // The row must mean "this goal skipped the scope gate". On a planning version
47
+ // the create was always going to succeed, so the override did nothing and
48
+ // recording it would libel a perfectly normal goal.
49
+ assert.equal(decide(V('building'), true).overrideApplied, true, 'building: the override did the work');
50
+ assert.equal(decide(V('planning'), true).overrideApplied, false, 'planning: nothing to override');
51
+ assert.equal(decide(V('planning')).overrideApplied, false, 'no override offered at all');
52
+ });
53
+
54
+ test('a refusal carries no overrideApplied claim', () => {
55
+ // ok:false has no admission story; asserting the field is absent stops a caller
56
+ // reading `.overrideApplied` off a rejection and getting a useful-looking false.
57
+ const d = decide(V('building'));
58
+ assert.equal(d.ok, false);
59
+ assert.equal('overrideApplied' in d, false);
60
+ });
61
+
62
+ // ---- and it stays a valve, not a hole --------------------------------------
63
+
64
+ test('the override never reopens closed history', () => {
65
+ for (const status of ['shipped', 'paused', '', null, undefined]) {
66
+ assert.equal(decide(V(status), true).ok, false, `'${String(status)}' must stay closed`);
67
+ assert.equal(decide(V(status), true).body.error, 'version_closed');
68
+ }
69
+ });
70
+
71
+ test('only a literal true overrides — a JSON body cannot coerce its way in', () => {
72
+ for (const v of ['yes', 1, {}, [], 'true']) {
73
+ assert.equal(decide(V('building'), v).ok, false);
74
+ }
75
+ });
76
+
77
+ // ---- the route: rank, reason, and the record -------------------------------
78
+
79
+ test('admit_reason is Archon-only, refused by rank before the version is read', () => {
80
+ const body = postGoals();
81
+ assert.match(body, /archon_required_for_admission/);
82
+ assert.match(body, /req\.builder\.rank !== 'archon'/);
83
+ // Ordering matters: the rank refusal sits above the getVersion call, so a Metic
84
+ // never learns whether the override would have worked on this version.
85
+ assert.ok(
86
+ body.indexOf('archon_required_for_admission') < body.indexOf('db.getVersion'),
87
+ 'the rank check must precede the version fetch'
88
+ );
89
+ });
90
+
91
+ test('the override cannot be requested without a reason', () => {
92
+ const body = postGoals();
93
+ // There is no boolean flag beside admit_reason — its presence IS the request,
94
+ // so "override with no cause" is unrepresentable rather than merely refused.
95
+ assert.equal(/admit(_reason)?\s*:\s*\{\s*type:\s*'boolean'/.test(body), false);
96
+ assert.match(body, /admit_reason:\s*\{\s*type:\s*'string',\s*minLength:\s*[1-9]/,
97
+ 'a non-empty minimum — "." is not a reason');
98
+ assert.match(body, /override:\s*admitReason !== null/,
99
+ 'the reason is what opens the gate');
100
+ });
101
+
102
+ test('whitespace is not a reason', () => {
103
+ assert.match(postGoals(), /req\.body\.admit_reason \|\| ''\)\.trim\(\) \|\| null/);
104
+ });
105
+
106
+ test('the record is written from the gate\'s verdict, not a second status read', () => {
107
+ const body = postGoals();
108
+ assert.match(body, /versionDecision\.overrideApplied/);
109
+ assert.equal(/version\.status\s*[=!]==/.test(body), false,
110
+ 'one place reasons about version status, and it is the gate');
111
+ });
112
+
113
+ test('goal and justification land in ONE statement — no goal without its reason', () => {
114
+ const db = readFileSync(new URL('../modules/lifecycle/db-goals.js', import.meta.url), 'utf8');
115
+ const fn = db.slice(db.indexOf('async function createGoal'), db.indexOf('async function getGoalAdmission'));
116
+ assert.match(fn, /INSERT INTO lifecycle_goal_version_admissions/,
117
+ 'the admission insert rides inside createGoal');
118
+ assert.ok(
119
+ fn.indexOf('INSERT INTO lifecycle_goal_version_admissions') < fn.indexOf('SELECT * FROM g`'),
120
+ 'as a CTE in the same query, so it cannot fail after the goal has landed'
121
+ );
122
+ assert.match(fn, /WHERE \$9::bigint IS NOT NULL/,
123
+ 'and writes nothing on the normal path');
124
+ });
125
+
126
+ test('the row carries the goal, the actor, the reason and the time', () => {
127
+ const sql = readFileSync(
128
+ new URL('../modules/lifecycle/migrations/lifecycle_008_goal_version_admissions.sql', import.meta.url), 'utf8');
129
+ for (const col of ['goal_id', 'admitted_by', 'reason', 'created_at']) {
130
+ assert.match(sql, new RegExp(`\\b${col}\\b`), `the done-when names ${col}`);
131
+ }
132
+ assert.match(sql, /reason\s+text NOT NULL/, 'an override with no stated cause is not an override');
133
+ assert.match(sql, /goal_id\s+bigint PRIMARY KEY/,
134
+ 'per-goal, never a blanket unlock — one row per goal, and no version-level switch');
135
+ const tables = [...sql.matchAll(/CREATE TABLE IF NOT EXISTS (\w+)/g)].map((m) => m[1]);
136
+ assert.deepEqual(tables, ['lifecycle_goal_version_admissions'],
137
+ 'exactly one table, keyed by goal — no second, version-level unlock switch beside it');
138
+ });
139
+
140
+ test('a module migration may reference core tables but never alter them', () => {
141
+ const sql = readFileSync(
142
+ new URL('../modules/lifecycle/migrations/lifecycle_008_goal_version_admissions.sql', import.meta.url), 'utf8');
143
+ assert.equal(/ALTER TABLE/i.test(sql), false, 'ADR 0083 §Decision #5');
144
+ assert.match(sql, /CREATE TABLE IF NOT EXISTS lifecycle_/, 'module-owned tables carry the module prefix');
145
+ });
146
+
147
+ // ---- the record is legible afterwards --------------------------------------
148
+
149
+ test('reading a goal shows whether it was admitted', () => {
150
+ const src = routeSrc();
151
+ const start = src.indexOf("router.get('/goals/:id'");
152
+ assert.notEqual(start, -1);
153
+ const body = src.slice(start, src.indexOf('router.', start + 20));
154
+ assert.match(body, /db\.getGoalAdmission\(id\)/);
155
+ assert.match(body, /admitted/, 'and returns it — an audit trail nobody can read is not one');
156
+ });
157
+
158
+ test('the create response says whether the override took effect', () => {
159
+ assert.match(postGoals(), /admitted \}\);/);
160
+ });
161
+
162
+ // ---- ADR 0250 §3: the exception is counted, or it is not an exception --------
163
+
164
+ test('the version aggregate carries the override count', () => {
165
+ const src = readFileSync(new URL('../modules/lifecycle/routes/versions.js', import.meta.url), 'utf8');
166
+ const start = src.indexOf("router.get('/versions/:id/progress'");
167
+ assert.notEqual(start, -1);
168
+ const body = src.slice(start, src.indexOf('router.', start + 20));
169
+ assert.match(body, /db\.listVersionAdmissions\(versionId\)/);
170
+ assert.match(body, /admission_count: admissions\.length/,
171
+ 'ADR 0250: an override that leaves no visible trace is a slower version of no rule');
172
+ });
173
+
174
+ test('the admissions query is scoped to one version and names the goal', () => {
175
+ const db = readFileSync(new URL('../modules/lifecycle/db-goals.js', import.meta.url), 'utf8');
176
+ const fn = db.slice(db.indexOf('async function listVersionAdmissions'), db.indexOf('// getGoalAdmission'));
177
+ assert.match(fn, /WHERE a\.version_id = \$1/, 'one version, not the whole ledger');
178
+ assert.match(fn, /g\.title AS goal_title/, 'a count with no goal names is not reviewable');
179
+ });
@@ -169,7 +169,7 @@ test('the advisory is advice on a 201, never a refusal', () => {
169
169
  // and never fails the create over it (best-effort, wrapped in try).
170
170
  const routes = read('modules', 'lifecycle', 'routes', 'goals.js');
171
171
  assert.match(routes, /category_advisory = goalCategory\.adviseCategory/);
172
- assert.match(routes, /res\.status\(201\)\.json\(\{ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory \}\)/,
172
+ assert.match(routes, /res\.status\(201\)\.json\(\{ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory, admitted \}\)/,
173
173
  'it rides the 201 — advice appended to a request that already succeeded');
174
174
  });
175
175
 
@@ -30,7 +30,8 @@ const decide = (version, override) => authorizeGoalCreate({ version, override })
30
30
  // ---- the truth table --------------------------------------------------------
31
31
 
32
32
  test('planning accepts — that is what a planning version is for', () => {
33
- assert.deepEqual(decide(V('planning')), { ok: true });
33
+ // overrideApplied false: nothing was overridden, the version was simply open.
34
+ assert.deepEqual(decide(V('planning')), { ok: true, overrideApplied: false });
34
35
  });
35
36
 
36
37
  test('building refuses with a named 409 an agent can act on', () => {
@@ -77,8 +78,8 @@ test('an unrecognised status is NOT described as shipped', () => {
77
78
 
78
79
  // ---- the override seam (R07 is not built yet) -------------------------------
79
80
 
80
- test('override admits a building version — the R07 seam, already shaped', () => {
81
- assert.deepEqual(decide(V('building'), true), { ok: true });
81
+ test('override admits a building version — the R07 seam, now wired (task 1003594)', () => {
82
+ assert.deepEqual(decide(V('building'), true), { ok: true, overrideApplied: true });
82
83
  });
83
84
 
84
85
  test('override does NOT reopen a shipped version', () => {
@@ -120,7 +121,9 @@ test('POST /goals consults the gate and returns its exact status + body', () =>
120
121
  const start = src.indexOf("router.post('/goals'");
121
122
  assert.notEqual(start, -1);
122
123
  const body = src.slice(start, src.indexOf('router.', start + 20));
123
- assert.match(body, /const versionDecision = authorizeGoalCreate\(\{ version \}\);/);
124
+ // BV1.R07 (task 1003594) wired the override argument in; the route still only
125
+ // renders whatever the gate decides.
126
+ assert.match(body, /const versionDecision = authorizeGoalCreate\(\{ version, override: [^)]*\}\);/);
124
127
  assert.match(body, /return res\.status\(versionDecision\.status\)\.json\(versionDecision\.body\);/,
125
128
  'the route renders the decision, it does not re-derive one');
126
129
  assert.equal(/version\.status\s*[=!]==/.test(body), false,
@@ -69,7 +69,9 @@ await test('createGoal: INSERT binds all six fields in order and returns the row
69
69
  // title, and task 1003275 appended `category_id` as $8 the same way, so $1–$6
70
70
  // keep the positions this test has always pinned. New goal fields go on the
71
71
  // END of the bind list for exactly this reason.
72
- assert.deepEqual(ins.params, ['BONGOS-V1', 'Goal hierarchy', 'desc', ['gds-core'], 'open', 42, null, null],
72
+ // $9/$10 are the BV1.R07 admission (task 1003594) null on the normal path,
73
+ // where nothing overrode the version scope gate.
74
+ assert.deepEqual(ins.params, ['BONGOS-V1', 'Goal hierarchy', 'desc', ['gds-core'], 'open', 42, null, null, null, null],
73
75
  'version_id, title, description, scope_modules, status, created_by, then subtitle, then category_id — in that order');
74
76
  assert.match(ins.sql, /RETURNING/, 'returns the row, not just inserts');
75
77
  });
@@ -91,7 +93,7 @@ await test('createGoal: defaults — null description/createdBy, empty scope, op
91
93
  const pool = fakePool((s) => (/INSERT INTO goals/.test(s) ? { rows: [GOAL_ROW] } : null));
92
94
  await createGoal({ versionId: 'V1', title: 'general' }, { pool });
93
95
  const ins = find(pool.queries, /INSERT INTO goals/);
94
- assert.deepEqual(ins.params, ['V1', 'general', null, [], 'open', null, null, null],
96
+ assert.deepEqual(ins.params, ['V1', 'general', null, [], 'open', null, null, null, null, null],
95
97
  'unspecified fields fall back to null / [] / open — subtitle ($7) and category_id ($8) default to null too');
96
98
  });
97
99