@bongos/core 1.19.569 → 1.19.571
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 +39 -24
- package/docs/adr/0257-auth-resolves-before-the-hall-mounts-anything.md +84 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +3 -3
- package/docs/copy-registry.json +3 -3
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/routes/copy-desk.js +4 -0
- package/modules/hall-ui/public/builders.js +42 -13
- package/modules/hall-ui/public/goals.js +10 -1
- package/modules/lifecycle/cascade.js +11 -0
- package/modules/lifecycle/db-tasks.js +37 -7
- package/modules/lifecycle/kickoff-seed.js +5 -0
- package/modules/lifecycle/routes/task-write-routes.js +12 -0
- package/modules/onboarding/newcomer-restock.js +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_landing_boot.mjs +141 -0
- package/tests/reward_gate.mjs +4 -4
- package/tests/task_goal_required.mjs +223 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.571",
|
|
6
|
+
"core_contract": "1.19.571",
|
|
7
|
+
"source_commit": "342ef455a76142077c47deaa4ef6db7b76139f1e",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-06T23:07:09.000Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 450,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2040,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2514,
|
|
20
|
+
"tree_sha256": "c508f7935f6abe6b6bdf02bab8820c895a17b9c1d3c689fab2c0e78ad6ce985b",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1799,10 +1799,15 @@
|
|
|
1799
1799
|
"mode": "0000644",
|
|
1800
1800
|
"sha256": "4b7d5b4ca5ad92a128235a6c3407617f2d8b34c26ed6e1c8b2a72e63eed7adac"
|
|
1801
1801
|
},
|
|
1802
|
+
{
|
|
1803
|
+
"path": "docs/adr/0257-auth-resolves-before-the-hall-mounts-anything.md",
|
|
1804
|
+
"mode": "0000644",
|
|
1805
|
+
"sha256": "5ebfaa564a125f2c5bb0b391b8ff01914f147c15c9ef3f4caa91bafa8bd2e8ed"
|
|
1806
|
+
},
|
|
1802
1807
|
{
|
|
1803
1808
|
"path": "docs/adr/README.md",
|
|
1804
1809
|
"mode": "0000644",
|
|
1805
|
-
"sha256": "
|
|
1810
|
+
"sha256": "e9b47055a52c284035c201fae3f172215c746caabf938922821f4efcda2e8980"
|
|
1806
1811
|
},
|
|
1807
1812
|
{
|
|
1808
1813
|
"path": "docs/api-reference.md",
|
|
@@ -1837,12 +1842,12 @@
|
|
|
1837
1842
|
{
|
|
1838
1843
|
"path": "docs/copy-inventory.md",
|
|
1839
1844
|
"mode": "0000644",
|
|
1840
|
-
"sha256": "
|
|
1845
|
+
"sha256": "8bdf39ea0b8ed9583f9ef68b0edae767eec9c2825f8ef355d678b3db3d08b5e1"
|
|
1841
1846
|
},
|
|
1842
1847
|
{
|
|
1843
1848
|
"path": "docs/copy-registry.json",
|
|
1844
1849
|
"mode": "0000644",
|
|
1845
|
-
"sha256": "
|
|
1850
|
+
"sha256": "cd51497ccca735bf1f8178291b427f4166aff79870a080a4a9d036238f0ff90c"
|
|
1846
1851
|
},
|
|
1847
1852
|
{
|
|
1848
1853
|
"path": "docs/design/apex-pass-2-direction.md",
|
|
@@ -2687,7 +2692,7 @@
|
|
|
2687
2692
|
{
|
|
2688
2693
|
"path": "docs/module-api-changelog.md",
|
|
2689
2694
|
"mode": "0000644",
|
|
2690
|
-
"sha256": "
|
|
2695
|
+
"sha256": "64941aa45c872838ad9b962b98fd750eea92110cfdb9b40bb16b0c57748f09c3"
|
|
2691
2696
|
},
|
|
2692
2697
|
{
|
|
2693
2698
|
"path": "docs/modules-contract.md",
|
|
@@ -3842,7 +3847,7 @@
|
|
|
3842
3847
|
{
|
|
3843
3848
|
"path": "modules/copy-desk/routes/copy-desk.js",
|
|
3844
3849
|
"mode": "0000644",
|
|
3845
|
-
"sha256": "
|
|
3850
|
+
"sha256": "63ed245f47d15758ef0926a489ad1fc7ef56d8539e836ed9e319e09b99a4a38e"
|
|
3846
3851
|
},
|
|
3847
3852
|
{
|
|
3848
3853
|
"path": "modules/copy-desk/tests/copy_flags.mjs",
|
|
@@ -4642,7 +4647,7 @@
|
|
|
4642
4647
|
{
|
|
4643
4648
|
"path": "modules/hall-ui/public/builders.js",
|
|
4644
4649
|
"mode": "0000644",
|
|
4645
|
-
"sha256": "
|
|
4650
|
+
"sha256": "ac1252ef25e0fe8b5774ef3d6566970657437de1151cd3e9e194859067fee789"
|
|
4646
4651
|
},
|
|
4647
4652
|
{
|
|
4648
4653
|
"path": "modules/hall-ui/public/chime.wav",
|
|
@@ -4822,7 +4827,7 @@
|
|
|
4822
4827
|
{
|
|
4823
4828
|
"path": "modules/hall-ui/public/goals.js",
|
|
4824
4829
|
"mode": "0000644",
|
|
4825
|
-
"sha256": "
|
|
4830
|
+
"sha256": "7800d109e109d61772edcf9fdd1f01dcd079a0937358f2ed8da6cbed361c1e9c"
|
|
4826
4831
|
},
|
|
4827
4832
|
{
|
|
4828
4833
|
"path": "modules/hall-ui/public/goals.states.json",
|
|
@@ -5407,7 +5412,7 @@
|
|
|
5407
5412
|
{
|
|
5408
5413
|
"path": "modules/lifecycle/cascade.js",
|
|
5409
5414
|
"mode": "0000644",
|
|
5410
|
-
"sha256": "
|
|
5415
|
+
"sha256": "d609ecb540af92f84e365fc7c4233b0b73517665939bcffe21d068de04167a6d"
|
|
5411
5416
|
},
|
|
5412
5417
|
{
|
|
5413
5418
|
"path": "modules/lifecycle/claim-eligibility.js",
|
|
@@ -5492,7 +5497,7 @@
|
|
|
5492
5497
|
{
|
|
5493
5498
|
"path": "modules/lifecycle/db-tasks.js",
|
|
5494
5499
|
"mode": "0000644",
|
|
5495
|
-
"sha256": "
|
|
5500
|
+
"sha256": "e978cadb879a57d8d7e3bb84657a787ac7e83125666fa2d31013ac64b37b0d7a"
|
|
5496
5501
|
},
|
|
5497
5502
|
{
|
|
5498
5503
|
"path": "modules/lifecycle/db-versions.js",
|
|
@@ -5577,7 +5582,7 @@
|
|
|
5577
5582
|
{
|
|
5578
5583
|
"path": "modules/lifecycle/kickoff-seed.js",
|
|
5579
5584
|
"mode": "0000644",
|
|
5580
|
-
"sha256": "
|
|
5585
|
+
"sha256": "d92124875d601a0ff7ad06bf140cdbce2f1160eb53ea950ccbb509b604733d1c"
|
|
5581
5586
|
},
|
|
5582
5587
|
{
|
|
5583
5588
|
"path": "modules/lifecycle/lifecycle.js",
|
|
@@ -5727,7 +5732,7 @@
|
|
|
5727
5732
|
{
|
|
5728
5733
|
"path": "modules/lifecycle/routes/task-write-routes.js",
|
|
5729
5734
|
"mode": "0000644",
|
|
5730
|
-
"sha256": "
|
|
5735
|
+
"sha256": "87843c62d8ce7af3d7d6aa5772791bd52823afe5f2c4b9b7427ca9f00dd14c11"
|
|
5731
5736
|
},
|
|
5732
5737
|
{
|
|
5733
5738
|
"path": "modules/lifecycle/routes/tasks.js",
|
|
@@ -5872,7 +5877,7 @@
|
|
|
5872
5877
|
{
|
|
5873
5878
|
"path": "modules/onboarding/newcomer-restock.js",
|
|
5874
5879
|
"mode": "0000644",
|
|
5875
|
-
"sha256": "
|
|
5880
|
+
"sha256": "65f8f2de9ac4582a6672ddeddc78b23e98d9b32865f7748efe58057145712161"
|
|
5876
5881
|
},
|
|
5877
5882
|
{
|
|
5878
5883
|
"path": "modules/onboarding/onboarding-state.js",
|
|
@@ -7537,12 +7542,12 @@
|
|
|
7537
7542
|
{
|
|
7538
7543
|
"path": "package-lock.json",
|
|
7539
7544
|
"mode": "0000644",
|
|
7540
|
-
"sha256": "
|
|
7545
|
+
"sha256": "6e8b9fee606ca0b7f97205d2cd7eace56e341a525232e39e3da81e05390da456"
|
|
7541
7546
|
},
|
|
7542
7547
|
{
|
|
7543
7548
|
"path": "package.json",
|
|
7544
7549
|
"mode": "0000644",
|
|
7545
|
-
"sha256": "
|
|
7550
|
+
"sha256": "5a2e1030453e82c8b52575c904e816e833343788d90e07c5d1946f15207fe8a4"
|
|
7546
7551
|
},
|
|
7547
7552
|
{
|
|
7548
7553
|
"path": "public-docs/index.html",
|
|
@@ -9242,7 +9247,7 @@
|
|
|
9242
9247
|
{
|
|
9243
9248
|
"path": "src/module-api.js",
|
|
9244
9249
|
"mode": "0000644",
|
|
9245
|
-
"sha256": "
|
|
9250
|
+
"sha256": "a92eeea78188a9a1658c5d6dc928062720189bf23511e2ca50d9a4eac5ef91ef"
|
|
9246
9251
|
},
|
|
9247
9252
|
{
|
|
9248
9253
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10714,6 +10719,11 @@
|
|
|
10714
10719
|
"mode": "0000644",
|
|
10715
10720
|
"sha256": "5654f4366ddcfcb61848a119a2dba3827f3fe19c0151005558a803c2e09db573"
|
|
10716
10721
|
},
|
|
10722
|
+
{
|
|
10723
|
+
"path": "tests/hall_landing_boot.mjs",
|
|
10724
|
+
"mode": "0000644",
|
|
10725
|
+
"sha256": "36711b8d885203ecb2f70ecd6aaf18359bc6796d50300bc8b062485a24ba49a4"
|
|
10726
|
+
},
|
|
10717
10727
|
{
|
|
10718
10728
|
"path": "tests/hall_nav.mjs",
|
|
10719
10729
|
"mode": "0000644",
|
|
@@ -11837,7 +11847,7 @@
|
|
|
11837
11847
|
{
|
|
11838
11848
|
"path": "tests/reward_gate.mjs",
|
|
11839
11849
|
"mode": "0000644",
|
|
11840
|
-
"sha256": "
|
|
11850
|
+
"sha256": "1ed242c879472dd872c1327c8e7c91de61886dcb108ece3fa455ae6be4b4e025"
|
|
11841
11851
|
},
|
|
11842
11852
|
{
|
|
11843
11853
|
"path": "tests/reward_mode_cost_plus_only.mjs",
|
|
@@ -12364,6 +12374,11 @@
|
|
|
12364
12374
|
"mode": "0000644",
|
|
12365
12375
|
"sha256": "93a5d1e91bfea7af8487b409e60cd609ea25a690df9099d278ebd35f253abcb8"
|
|
12366
12376
|
},
|
|
12377
|
+
{
|
|
12378
|
+
"path": "tests/task_goal_required.mjs",
|
|
12379
|
+
"mode": "0000644",
|
|
12380
|
+
"sha256": "915711866bc194d977225326511c55ac5084b52d10a45a855bc9d6da815f4b8b"
|
|
12381
|
+
},
|
|
12367
12382
|
{
|
|
12368
12383
|
"path": "tests/task_lib.mjs",
|
|
12369
12384
|
"mode": "0000644",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# ADR 0257 — Auth resolves before the hall mounts anything, and a widget's boot read may never navigate
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-09-06
|
|
5
|
+
- **Task:** [task 1003673](https://cloudbongos.com/builders#/task/1003673) (BONGOS-V1, goal 1000063 — *The front door*)
|
|
6
|
+
- **Deciders:** Claude, under the Archon's standing scope
|
|
7
|
+
- **Related:** [ADR 0050](<redacted>.md) (admission is invite-gated by default — the posture this defect made unusable) · [ADR 0194](<redacted>.md) + [ADR 0247](<redacted>.md) (the door the landing form renders) · [ADR 0062 §4](<redacted>.md) (the hall-widget registry)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Context
|
|
12
|
+
|
|
13
|
+
An owner stood up an instance, invited a colleague, and watched them sign in with
|
|
14
|
+
GitHub. They got *"Sign-in failed — this account is not approved yet. New here?
|
|
15
|
+
Request access first"*, whose one button goes to `/builders`. The owner then went
|
|
16
|
+
looking for the approval queue and found nothing to approve: `GET /access-requests`
|
|
17
|
+
answered `{"requests": []}`, and the hall hides the Access-requests panel when the
|
|
18
|
+
queue is empty. Both halves are working as designed — **signing in does not file a
|
|
19
|
+
request**; only the landing page's *Request access* form does.
|
|
20
|
+
|
|
21
|
+
The landing page could not be reached. A signed-out visitor to `/builders` was
|
|
22
|
+
bounced to GitHub, refused as a first-timer, and pointed back at `/builders` to be
|
|
23
|
+
bounced again. A closed loop, and the form sat inside it. Every invite-only
|
|
24
|
+
instance — the default posture since ADR 0050 — therefore had **no way to admit its
|
|
25
|
+
first builder**, and the symptom pointed at the approval UI rather than at the door.
|
|
26
|
+
|
|
27
|
+
Reproduced against `main` (not only against an old pin) by serving
|
|
28
|
+
`modules/hall-ui/public` statically with every authenticated route answering 401:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
GET /api/bongos/me 401 (soft — builders.js would renderLanding())
|
|
32
|
+
GET /api/bongos/goals 401 -> window.location.assign(/auth/web/start)
|
|
33
|
+
GET /api/bongos/auth/web/start the dead end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`builders.js` was already careful: `/me` is read with `softAuth`, and a 401 there
|
|
37
|
+
renders the landing rather than bouncing. But its `DOMContentLoaded` handler called
|
|
38
|
+
`mountHallWidgets()` **first**, synchronously. `goals.js`'s `mount()` reads `/goals`
|
|
39
|
+
through a `getJSON` with no `softAuth`, and that helper answers 401 by *navigating*.
|
|
40
|
+
The widget's read therefore raced `renderLanding()` — and won.
|
|
41
|
+
|
|
42
|
+
## Decision
|
|
43
|
+
|
|
44
|
+
**1. The front door settles which page this is before anything else runs.**
|
|
45
|
+
`builders.js` boots through `boot()`: read `/me` (soft), and only then choose.
|
|
46
|
+
Signed out → `renderLanding()` and stop; nothing else mounts, nothing else fetches.
|
|
47
|
+
Signed in → mount widgets, wire events, render the hall. The resolved `/me` payload
|
|
48
|
+
is handed to `loadAll()` rather than re-fetched, so the page still asks once.
|
|
49
|
+
|
|
50
|
+
Widgets are member surfaces — `renderLanding()` already hid every one of them after
|
|
51
|
+
the fact — so not mounting them for a signed-out visitor removes a whole class of
|
|
52
|
+
races rather than the one instance of it that was found.
|
|
53
|
+
|
|
54
|
+
**2. A widget's own boot read may never navigate.** `goals.js`'s mount-time read is
|
|
55
|
+
`softAuth`, and answers "no session" by rendering nothing. A 401 during page boot is
|
|
56
|
+
not an error and not an instruction to go and sign in; it is the answer *this visitor
|
|
57
|
+
has no session*, which the landing already handles. Only a user action may send
|
|
58
|
+
someone to the sign-in flow.
|
|
59
|
+
|
|
60
|
+
## Consequences
|
|
61
|
+
|
|
62
|
+
- An invite-only instance can admit its first builder: the landing renders, the
|
|
63
|
+
request form is reachable, the queue fills, and the owner has something to approve.
|
|
64
|
+
- The "Sign-in failed" page's CTA now lands somewhere useful — the loop is closed at
|
|
65
|
+
both ends.
|
|
66
|
+
- `tests/hall_landing_boot.mjs` executes the real `builders.js` in a DOM stub and
|
|
67
|
+
asserts the invariant three ways: a signed-out boot never navigates, renders the
|
|
68
|
+
landing, and mounts no widgets; it asks for `/me` and nothing else; and a signed-in
|
|
69
|
+
boot still mounts the hall on a single `/me`. The guard was confirmed to FAIL
|
|
70
|
+
against the pre-fix file — a regression test that passes both ways guards nothing.
|
|
71
|
+
- A widget added later can still reintroduce the class if it hard-redirects at mount
|
|
72
|
+
AND something mounts it while signed out. The boot order is what makes that
|
|
73
|
+
unreachable; the `softAuth` rule is the belt beside it.
|
|
74
|
+
|
|
75
|
+
## Rejected
|
|
76
|
+
|
|
77
|
+
- **Patch `goals.js` alone.** It was the widget that happened to fire first. Any
|
|
78
|
+
mount-time authenticated read can steal the page, so the fix belongs at the boot
|
|
79
|
+
order, not at whichever widget was caught doing it.
|
|
80
|
+
- **Make the 401 → redirect helper soft everywhere.** A signed-in builder whose
|
|
81
|
+
session expires mid-visit *should* be sent to sign in. The distinction that matters
|
|
82
|
+
is boot versus user action, not the status code.
|
|
83
|
+
- **Open enrollment** (`OPEN_XENOS_ENROLLMENT`) as the cure. That removes the gate
|
|
84
|
+
instead of repairing the door, and ADR 0050 chose the gate deliberately.
|
package/docs/adr/README.md
CHANGED
|
@@ -348,3 +348,4 @@ This keeps the decision history honest and traceable.
|
|
|
348
348
|
| 0246 | [**Catalog membership is a projection of the provisioning row, not a side effect of federation** ([task 1003576](https://cloudbongos.com/builders#/task/1003576) · goal 1000046, criterion `proj-detail-interview` / C3). The BV1.R18 proof ([task 1002337](https://cloudbongos.com/builders#/task/1002337)) demonstrated C3 against live and found its last clause — “then it publishes” — structurally false: the owner’s verdict reads `provisioning_instances` while the public map reads `platform_identity_projects`, and the ONLY bridge was `federateInstance → registerClient → upsertProject`, which `shouldFederate` refuses for anything but `co-tenant` (the wizard hard-codes `standalone`) and which runs ONCE, at provision time, through a COALESCE. So a wizard-made project was never catalogued at all and a co-tenant one that answered later never updated — `publishable: true`, `{"projects": []}`, forever. The same missing row is [task 1003580](https://cloudbongos.com/builders#/task/1003580)’s GAP 1: `POST /projects/invite` resolves against `platform_identity_sso_clients`, whose only writer is that same `registerClient`. Underneath is one conflation — federation mints CREDENTIALS and was incidentally the only thing that minted MEMBERSHIP, while [ADR 0182](<redacted>.md) D4 already fixed the map’s gate at IDENTITY, which has nothing to do with hosting topology. **Decision: the catalog row is a PROJECTION of the provisioning row, re-derived at every write that can change its inputs** — two halves, neither sufficient alone. (1) THE ROW SHAPE: every control-plane project gets both rows whatever its hosting shape, and the client row carries NO credential (`secret_hash = ''`, `redirect_uris = '{}'`), so **the shape is the enforcement** rather than a flag a caller could forget — `redirectUriAllowed` refuses `/sso/authorize`, `clientSecretValid` refuses `/sso/token`, `/sso/membership/check-in` and `/sso/activity/rollup`, and the one leg that checked NEITHER (`/sso/device/start`, which resolves a client from a user-typed origin) grew an explicit `clientIsCredentialed` guard. (2) THE TIMING: re-taken on create, on `PATCH …/detail`, on `PATCH …/settings` when the door changes, and on `POST …/domain` (which re-keys the row, retiring the abandoned origin — [ADR 0181](0181-teardown-state-gate-and-address-retention.md) §2’s leak, in the catalog instead of DNS). **NO MIGRATION** — `secret_hash` is `NOT NULL` and `''` satisfies it while hashing to nothing. `description` and `visibility` are ASSIGNED, not COALESCEd (clearing a description must UN-publish); `featured`/`tagline`/`art_url`/`template` are curation and stay untouched, so [ADR 0182](<redacted>.md) D3 holds. It grants nothing — the row is attribution per [ADR 0238](<redacted>.md), ownership stays `provisioning_instances.owner_builder_id` — and because it writes rows keyed on owner-supplied values (a slug, a BYO domain) each write carries its **claim rule in the ON CONFLICT action’s own WHERE**: the client is refreshed only `WHERE secret_hash = ''` (a real client’s origin, which binds the CLI mint audience, can never be rewritten) and the project only `WHERE owner_github_id = EXCLUDED.owner_github_id` (`NULL = anything` is not true, so the hand-seeded rows cannot be claimed). Writing `visibility` does not contradict [ADR 0182](<redacted>.md) D5: the hub authorizes nothing — the authority already ran on the provisioning plane ([ADR 0238](<redacted>.md)’s own-scoped, audited PATCH), and a project the control plane does NOT own has no provisioning row, so D5’s instance report (R08) stays the only channel for those. `tearing_down`/`torn_down` project `hidden`; a `no_address` instance is not catalogued at all. Travels on the kernel port `project.catalog`, the `project.publishGate` seam in the other direction ([ADR 0083](<redacted>.md) forbids the import). Rejected: extending `shouldFederate` to every shape (hands a real secret to instances whose web.env the platform does not write, and keeps the once-at-standup bug); bridging only at the publish moment (leaves the invite with nothing to resolve); registering only at standup (the bug itself); a `catalog_only` boolean column (needs a migration and is weaker than the shape); letting the bridge write `featured`; and backfilling the three existing catalog rows from code.](<redacted>.md) | provisioning / project creation |
|
|
349
349
|
|
|
350
350
|
> ⚠️ **Numbering collisions are now machine-enforced, not narrated.** Twenty numbers are shared by two ADRs each — assigned in parallel sessions before anything checked. The files keep their filenames (renumbering would break every existing citation, and a number once assigned is never reused), and this table disambiguates each pair as `NNNN-a` / `NNNN-b`. The authoritative list is `LEGACY_DUPLICATE_ADRS` in [`scripts/gds/adr-namespace.js`](../../scripts/gds/adr-namespace.js), frozen by exact filename: a **new** collision — or a third file joining a legacy number, or a rename of either half — hard-fails the `unit` CI gate, as does an index row that points at no file or an ADR with no row. The list may only shrink. This paragraph used to narrate the collisions one by one and had fallen eight behind reality; see [ADR 0195](<redacted>.md) for why the check exists and why the pairs are grandfathered rather than renumbered.
|
|
351
|
+
| 0257 | [**Auth resolves before the hall mounts anything, and a widget's boot read may never navigate** ([task 1003673](https://cloudbongos.com/builders#/task/1003673) · goal 1000063 — *The front door*). An invite-only instance could not admit its FIRST builder, and the symptom lied about where the fault was: an owner saw an empty Access-requests queue and no approve button, because **signing in does not file a request** — only the landing's *Request access* form does, and that form was unreachable. A signed-out visitor to `/builders` was bounced to GitHub, refused as a first-timer ("request access first"), and pointed back at `/builders` to be bounced again. Reproduced against `main`, not just an old pin. `builders.js` was already careful — `/me` is read `softAuth` and a 401 there renders the landing — but `DOMContentLoaded` called `mountHallWidgets()` FIRST, synchronously, and `goals.js`'s mount-time read of `/goals` goes through a `getJSON` with no `softAuth`, which answers 401 by NAVIGATING. The widget's read raced `renderLanding()` and won. **Decision: the front door settles which page this is before anything else runs** — `boot()` reads `/me` (soft), then either renders the landing and stops (nothing mounts, nothing else fetches) or mounts the hall, handing the resolved payload to `loadAll()` so the page still asks once. Widgets are member surfaces (`renderLanding` hid them all after the fact anyway), so not mounting them while signed out removes the CLASS rather than the one instance of it that was found; **a widget's own boot read may never navigate** is the belt beside it (`goals.js` reads soft and renders nothing without a session). A 401 during boot is not an instruction to go and sign in — only a user action is. `tests/hall_landing_boot.mjs` executes the real `builders.js` in a DOM stub and was confirmed to FAIL against the pre-fix file. Rejected: patching `goals.js` alone (it was merely first); making the 401 redirect soft everywhere (an expired session mid-visit SHOULD be sent to sign in — the line is boot vs user action); open enrollment as the cure (removes the gate [ADR 0050](<redacted>.md) chose deliberately instead of repairing the door).](<redacted>.md) | hall-ui / admission / front door |
|
package/docs/copy-inventory.md
CHANGED
|
@@ -135,7 +135,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
|
|
|
135
135
|
| `1b326b0be1a4` | certain | A {…} — you are a {…}. Mingle pairs across crafts. | `modules/hall-ui/public/collab.js:196` |
|
|
136
136
|
| `996edffc1966` | certain | Access requests | `modules/hall-ui/public/watch.html:46` |
|
|
137
137
|
| `996edffc1966` | certain | Access requests | `modules/hall-ui/public/watch.html:61` |
|
|
138
|
-
| `303f8d90266f` | certain | Achievements could not be loaded. | `modules/hall-ui/public/builders.js:
|
|
138
|
+
| `303f8d90266f` | certain | Achievements could not be loaded. | `modules/hall-ui/public/builders.js:1158` |
|
|
139
139
|
| `6ca31dd5eb91` | certain | Acknowledge the primer | `modules/hall-ui/public/primer.html:99` |
|
|
140
140
|
| `4461b13f95c2` | certain | Actions apply at once. Closing stops and deletes the box; you can start a fresh one anytime. | `modules/hall-ui/public/settings.html:209` |
|
|
141
141
|
| `524188fe0078` | certain | Active sessions | `modules/hall-ui/public/settings.html:162` |
|
|
@@ -445,7 +445,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
|
|
|
445
445
|
| `529460e82058` | certain | Goal invitations to you | `modules/hall-ui/public/goal-inbox.js:113` |
|
|
446
446
|
| `e2a9e33abe60` | certain | Goal tasks | `modules/hall-ui/public/goals.html:136` |
|
|
447
447
|
| `e0bc28d10785` | certain | Goals could not be loaded. Refresh to try again. | `modules/hall-ui/public/goals-page.js:890` |
|
|
448
|
-
| `4517f2253d0f` | certain | Goals couldn’t be loaded just now. Refresh to try again. | `modules/hall-ui/public/goals.js:
|
|
448
|
+
| `4517f2253d0f` | certain | Goals couldn’t be loaded just now. Refresh to try again. | `modules/hall-ui/public/goals.js:121` |
|
|
449
449
|
| `6a85997bda21` | certain | Goals created | `modules/hall-ui/public/profile.html:145` |
|
|
450
450
|
| `2ed2cb8fbffd` | certain | Goals page | `modules/hall-ui/public/goals.js:94` |
|
|
451
451
|
| `320a0516a33e` | certain | Goals seeded — awaiting kickoff | `modules/hall-ui/public/roadmap.js:205` |
|
|
@@ -907,7 +907,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
|
|
|
907
907
|
| `381f29be7497` | certain | The most recently shipped tasks. | `modules/hall-ui/public/work.html:108` |
|
|
908
908
|
| `82479c539fca` | certain | The name shown beside your work — in the roster, the leaderboard, and the work feed. Your sign-in and the cred | `modules/hall-ui/public/settings.html:75` |
|
|
909
909
|
| `b88dd8b96b72` | certain | The next person to notice this sentence reads your note instead of re-litigating it. | `modules/hall-ui/public/copy-desk.html:135` |
|
|
910
|
-
| `aa716f3dd551` | certain | The page could not be loaded. Try again. | `modules/hall-ui/public/builders.js:
|
|
910
|
+
| `aa716f3dd551` | certain | The page could not be loaded. Try again. | `modules/hall-ui/public/builders.js:1425` |
|
|
911
911
|
| `74bc22f38140` | certain | The platform confirmed this request came from the account it names. | `modules/hall-ui/public/watch.js:349` |
|
|
912
912
|
| `6c3d88de42de` | certain | The primer | `modules/hall-ui/public/primer.html:41` |
|
|
913
913
|
| `dea378cd4837` | certain | The primer did not load | `modules/hall-ui/public/primer.html:88` |
|
package/docs/copy-registry.json
CHANGED
|
@@ -2211,7 +2211,7 @@
|
|
|
2211
2211
|
"surface": "builders-hall",
|
|
2212
2212
|
"text": "Achievements could not be loaded.",
|
|
2213
2213
|
"file": "modules/hall-ui/public/builders.js",
|
|
2214
|
-
"line":
|
|
2214
|
+
"line": 1158,
|
|
2215
2215
|
"origin": "js-markup",
|
|
2216
2216
|
"confidence": "certain"
|
|
2217
2217
|
},
|
|
@@ -5001,7 +5001,7 @@
|
|
|
5001
5001
|
"surface": "builders-hall",
|
|
5002
5002
|
"text": "Goals couldn’t be loaded just now. Refresh to try again.",
|
|
5003
5003
|
"file": "modules/hall-ui/public/goals.js",
|
|
5004
|
-
"line":
|
|
5004
|
+
"line": 121,
|
|
5005
5005
|
"origin": "js-markup",
|
|
5006
5006
|
"confidence": "certain"
|
|
5007
5007
|
},
|
|
@@ -9159,7 +9159,7 @@
|
|
|
9159
9159
|
"surface": "builders-hall",
|
|
9160
9160
|
"text": "The page could not be loaded. Try again.",
|
|
9161
9161
|
"file": "modules/hall-ui/public/builders.js",
|
|
9162
|
-
"line":
|
|
9162
|
+
"line": 1425,
|
|
9163
9163
|
"origin": "js-notice",
|
|
9164
9164
|
"confidence": "certain"
|
|
9165
9165
|
},
|
|
@@ -1587,5 +1587,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1587
1587
|
landed since 1.19.567 with no explicit bump. run 34058174866. (task 1002620)
|
|
1588
1588
|
1.19.569 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1589
1589
|
landed since 1.19.568 with no explicit bump. run 34058387853. (task 1002620)
|
|
1590
|
+
1.19.570 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1591
|
+
landed since 1.19.569 with no explicit bump. run 34060442118. (task 1002620)
|
|
1592
|
+
1.19.571 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1593
|
+
landed since 1.19.570 with no explicit bump. run 34065943703. (task 1002620)
|
|
1590
1594
|
---------------------------------------------------------------------------
|
|
1591
1595
|
```
|
|
@@ -415,6 +415,10 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
415
415
|
const body = proposals.proposalTaskBody(patch);
|
|
416
416
|
const task = await lifecycle.createTask({
|
|
417
417
|
versionId: building.id,
|
|
418
|
+
// BV1.R11 (task 1003598): no goal to name until the per-version maintenance
|
|
419
|
+
// goal exists (task 1003605, R18). Declared out loud rather than relying on
|
|
420
|
+
// a silent default — see createTask.
|
|
421
|
+
allowCatchAll: true,
|
|
418
422
|
title: proposals.proposalTaskTitle(patch),
|
|
419
423
|
description: flagRef
|
|
420
424
|
? `${body}\n\nRaised from copy-desk flag ${flagRef}.`
|
|
@@ -1044,16 +1044,20 @@
|
|
|
1044
1044
|
|
|
1045
1045
|
// ---- bootstrap -----------------------------------------------------------
|
|
1046
1046
|
|
|
1047
|
-
|
|
1047
|
+
// `prefetchedMe` is boot()'s already-resolved /me — the SAME payload, handed in
|
|
1048
|
+
// so the page does not ask twice. Called with nothing (a re-render), it fetches.
|
|
1049
|
+
async function loadAll(prefetchedMe) {
|
|
1048
1050
|
// Profile first. If /me returns 401, render an unauth landing card with a
|
|
1049
1051
|
// Sign-in button instead of bouncing straight to GitHub — gives the visitor
|
|
1050
1052
|
// a beat of orientation about what they're walking into.
|
|
1051
|
-
let me;
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1053
|
+
let me = prefetchedMe || null;
|
|
1054
|
+
if (!me) {
|
|
1055
|
+
try {
|
|
1056
|
+
me = await getJSON(`${API}/me`, { softAuth: true });
|
|
1057
|
+
} catch (err) {
|
|
1058
|
+
if (err && err.code === AUTH_NEEDED) { renderLanding(); return; }
|
|
1059
|
+
throw err;
|
|
1060
|
+
}
|
|
1057
1061
|
}
|
|
1058
1062
|
// Signed in: drop the landing card if a previous unauth render left it up.
|
|
1059
1063
|
const landingRoot = $('#landing-root');
|
|
@@ -1374,7 +1378,31 @@
|
|
|
1374
1378
|
get newcomerGated() { return newcomerGated; },
|
|
1375
1379
|
});
|
|
1376
1380
|
|
|
1377
|
-
|
|
1381
|
+
// boot — AUTH FIRST, then the page (task 1003673).
|
|
1382
|
+
//
|
|
1383
|
+
// The front door has exactly two shapes, and which one this is must be settled
|
|
1384
|
+
// BEFORE anything else runs. A signed-out visitor gets the landing (the door:
|
|
1385
|
+
// sign in, or request access) and nothing else; a builder gets the hall.
|
|
1386
|
+
//
|
|
1387
|
+
// Why the order is load-bearing: mountHallWidgets() mounts every registered
|
|
1388
|
+
// widget, and a widget's mount() reads its own data. Any such read that treats
|
|
1389
|
+
// a 401 as "go and sign in" NAVIGATES — and mounting before /me resolved gave
|
|
1390
|
+
// those reads a head start on renderLanding(), so the signed-out visitor was
|
|
1391
|
+
// bounced to GitHub, refused as a first-timer ("request access first"), and
|
|
1392
|
+
// sent back here to be bounced again. A closed loop: the request form was
|
|
1393
|
+
// unreachable, so an invite-only instance could never admit its first builder.
|
|
1394
|
+
// Widgets are for members, so a signed-out visitor simply never mounts them
|
|
1395
|
+
// (renderLanding hides those sections anyway) and nothing can steal the page.
|
|
1396
|
+
async function boot() {
|
|
1397
|
+
let me = null;
|
|
1398
|
+
try {
|
|
1399
|
+
me = await getJSON(`${API}/me`, { softAuth: true });
|
|
1400
|
+
} catch (err) {
|
|
1401
|
+
if (!(err && err.code === AUTH_NEEDED)) throw err;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
if (!me) { renderLanding(); return; }
|
|
1405
|
+
|
|
1378
1406
|
// BV1.R75: stamp the registry's <section>s into <main> BEFORE wiring events —
|
|
1379
1407
|
// wireEvents() + the data-render code address the widgets by their static ids,
|
|
1380
1408
|
// which only exist once the registry has mounted them.
|
|
@@ -1386,11 +1414,12 @@
|
|
|
1386
1414
|
// #731: the Discord connect/link card moved to /builders/settings, and the
|
|
1387
1415
|
// OAuth callback now lands there — so the ?discord=joined|linked toast is
|
|
1388
1416
|
// handled by settings.js, not here.
|
|
1389
|
-
loadAll()
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1417
|
+
await loadAll(me);
|
|
1418
|
+
if (window.__buildersSelfId) startLiveUpdates();
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1422
|
+
boot().catch((err) => {
|
|
1394
1423
|
if (err && err.message === 'redirecting to auth') return;
|
|
1395
1424
|
console.error('[builders] load failed', err);
|
|
1396
1425
|
toast('The page could not be loaded. Try again.');
|
|
@@ -101,7 +101,13 @@
|
|
|
101
101
|
const me = await getJSON(`${API}/me`, { softAuth: true }).catch(() => null);
|
|
102
102
|
myId = me && me.builder ? me.builder.id : null;
|
|
103
103
|
}
|
|
104
|
-
|
|
104
|
+
// softAuth (task 1003673): a widget's own boot read may NEVER navigate.
|
|
105
|
+
// This ran at mount time and its 401 hard-redirected to GitHub, which is
|
|
106
|
+
// what stole the page from a signed-out visitor before the landing could
|
|
107
|
+
// paint. boot() no longer mounts widgets for a signed-out visitor; this
|
|
108
|
+
// keeps the widget itself harmless if it is ever mounted without a session
|
|
109
|
+
// (an expired cookie mid-visit), where the honest answer is to say nothing.
|
|
110
|
+
const gres = await getJSON(`${API}/goals`, { softAuth: true });
|
|
105
111
|
const candidates = (gres.goals || []).filter((g) => g.status !== 'archived');
|
|
106
112
|
const details = (await Promise.all(candidates.map((g) =>
|
|
107
113
|
getJSON(`${API}/goals/${encodeURIComponent(g.id)}`).catch(() => null)))).filter(Boolean);
|
|
@@ -109,6 +115,9 @@
|
|
|
109
115
|
render(joined);
|
|
110
116
|
} catch (err) {
|
|
111
117
|
const body = host.querySelector('#goals-body');
|
|
118
|
+
// Signed out is not a failure — it is simply nothing to show. Only a real
|
|
119
|
+
// error earns the retry line (and the console noise).
|
|
120
|
+
if (err && err.code === 'auth-needed') { if (body) body.innerHTML = ''; return; }
|
|
112
121
|
if (body) body.innerHTML = '<p class="goal-empty">Goals couldn’t be loaded just now. Refresh to try again.</p>';
|
|
113
122
|
console.error('[hall] goals load failed', err);
|
|
114
123
|
}
|
|
@@ -242,7 +242,18 @@ function cascadeTaskSpec(rule, task) {
|
|
|
242
242
|
+ `the rule rather than only this task.`;
|
|
243
243
|
return {
|
|
244
244
|
versionId: task.version_id,
|
|
245
|
+
// A cascade INHERITS its parent's goal — the follow-up belongs wherever the
|
|
246
|
+
// work that triggered it belongs.
|
|
245
247
|
goalId: task.goal_id != null ? Number(task.goal_id) : null,
|
|
248
|
+
// BV1.R11 (task 1003598): …and when the parent has none, says so. A shipped
|
|
249
|
+
// task can still be goal-less — the 53 legacy catch-all rows, or any task
|
|
250
|
+
// whose version had no general goal to fall into — and under the new guard a
|
|
251
|
+
// null goalId THROWS. That throw is caught by the per-rule try/catch below,
|
|
252
|
+
// so the ship is never at risk, but the cascade task would vanish with only a
|
|
253
|
+
// console line: a silent goal-less gap, which is the exact thing this rule
|
|
254
|
+
// exists to close. Declared only when inheritance actually failed, so a
|
|
255
|
+
// cascade off a normal task still names a real goal.
|
|
256
|
+
allowCatchAll: task.goal_id == null,
|
|
246
257
|
title: String(rule.title(task) || '').trim(),
|
|
247
258
|
description: `${preamble}\n\n${body}`,
|
|
248
259
|
discipline: rule.discipline,
|
|
@@ -116,6 +116,13 @@ async function createTask({
|
|
|
116
116
|
// path with no builder in the request (a seed, a migration backfill) passes
|
|
117
117
|
// nothing and the row honestly reads unattributed, never guessed.
|
|
118
118
|
createdBy = null,
|
|
119
|
+
// BV1.R11 (task 1003598): the ESCAPE HATCH, and it is meant to be loud. A
|
|
120
|
+
// create with no goalId throws unless this is true. It exists for the two
|
|
121
|
+
// in-repo vectors that have no goal to name until the per-version
|
|
122
|
+
// maintenance goal lands (task 1003605, R18) — newcomer-restock and
|
|
123
|
+
// copy-desk. Grep for it before adding a third: the answer is almost always
|
|
124
|
+
// to name a goal instead.
|
|
125
|
+
allowCatchAll = false,
|
|
119
126
|
}, deps = {}) { // deps.client enlists this INSERT in the CALLER'S transaction (task 1003065, BV1.R01); the argument for it is at modules/ideas/inbox.js routeIdeaToGoal
|
|
120
127
|
// task 1498 / ADR 0085: every task carries a deliberate rank floor. Derive it
|
|
121
128
|
// from touches + sensitivity, then let an explicit requiresRank override only
|
|
@@ -140,15 +147,38 @@ async function createTask({
|
|
|
140
147
|
if (rewardMissing(creditsRewardEffective)) {
|
|
141
148
|
creditsRewardEffective = suggestCreditsReward({ est_minutes: estMinutes, kind });
|
|
142
149
|
}
|
|
143
|
-
// task
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
+
// BV1.R11 (task 1003598, goal 1000086, ADR 0250 D4): a goal-less create is no
|
|
151
|
+
// longer SILENT. Task 1763 defaulted any create without a goalId into its
|
|
152
|
+
// version's catch-all "<version> — general" goal, here at the lowest layer, so
|
|
153
|
+
// no caller had to think about it — and that is exactly how 53 open tasks
|
|
154
|
+
// accumulated in two catch-alls that nobody reads. Unscoped work did not sneak
|
|
155
|
+
// past the rule; the rule quietly filed it out of sight.
|
|
156
|
+
//
|
|
157
|
+
// Now a caller either NAMES a goal or says out loud that it cannot, by passing
|
|
158
|
+
// `allowCatchAll: true`. Same landing place, same never-break-a-creator
|
|
159
|
+
// promise — but the exceptions are a one-command grep instead of an absence,
|
|
160
|
+
// and a new create vector added next year gets a thrown error at its first
|
|
161
|
+
// test run rather than a silent home in a bucket.
|
|
162
|
+
//
|
|
163
|
+
// WHY NOT DELETE THE FALLBACK OUTRIGHT, which is what R11 literally asks: two
|
|
164
|
+
// in-repo callers have no goal to name yet. `newcomer-restock` stamps the
|
|
165
|
+
// beginner queue from templates and `copy-desk` files copy proposals; the
|
|
166
|
+
// honest home for both is the per-version MAINTENANCE goal, which is task
|
|
167
|
+
// 1003605 (R18) and still behind R06 and R12. Deleting the fallback today
|
|
168
|
+
// would stop both — the newcomer queue would drain — so the silent half is
|
|
169
|
+
// removed now and the true deletion belongs to R18, which is when the last two
|
|
170
|
+
// opt-ins can name a real goal. Owner's call, recorded here so the remaining
|
|
171
|
+
// gap is not mistaken for an oversight.
|
|
150
172
|
let goalIdEffective = goalId;
|
|
151
173
|
if (goalIdEffective == null) {
|
|
174
|
+
if (allowCatchAll !== true) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
'createTask: goalId is required — every task belongs to a goal (BV1.R11). ' +
|
|
177
|
+
'A vector that genuinely cannot name one yet must pass allowCatchAll: true, ' +
|
|
178
|
+
'which files into the version catch-all and is greppable until R18 gives it ' +
|
|
179
|
+
'a maintenance goal.'
|
|
180
|
+
);
|
|
181
|
+
}
|
|
152
182
|
goalIdEffective = await generalGoalIdForVersion(versionId);
|
|
153
183
|
}
|
|
154
184
|
const { rows } = await (deps.client || pool).query(
|
|
@@ -60,6 +60,11 @@ async function seedKickoffIfEmpty({ builderId = null } = {}, deps = {}) {
|
|
|
60
60
|
discipline: t.discipline || 'engineer',
|
|
61
61
|
source: 'first-sign-in',
|
|
62
62
|
createdBy: builderId,
|
|
63
|
+
// BV1.R11 (task 1003598): this runs on an instance with zero tasks and, in
|
|
64
|
+
// the scaffold's own seed order, zero goals — there is literally nothing to
|
|
65
|
+
// name yet. Declared rather than defaulted; the founder's first board is the
|
|
66
|
+
// one place a catch-all is the honest answer.
|
|
67
|
+
allowCatchAll: true,
|
|
63
68
|
});
|
|
64
69
|
seeded += 1;
|
|
65
70
|
}
|
|
@@ -165,6 +165,18 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
165
165
|
// Validate an explicit goal_id: it must exist AND belong to THIS version (a task
|
|
166
166
|
// can't be parented under another version's goal). Absent → the default-goal
|
|
167
167
|
// trigger files it under the version's catch-all goal, exactly as before.
|
|
168
|
+
// BV1.R11 (task 1003598, goal 1000086, ADR 0250 D4): goal_id is REQUIRED on
|
|
169
|
+
// this vector. It used to be optional and a goal-less task was filed into the
|
|
170
|
+
// version catch-all, which is how 53 open tasks ended up in two buckets
|
|
171
|
+
// nobody reads. This is the human/agent door, and it always has a goal to
|
|
172
|
+
// name — /builder-start lists work by goal, and POST /goals/:id/tasks has
|
|
173
|
+
// required one since BV1.R60.
|
|
174
|
+
if (body.goal_id == null) {
|
|
175
|
+
return res.fail('goal_id_required', {
|
|
176
|
+
status: 400,
|
|
177
|
+
message: 'goal_id is required — every task belongs to a goal. List them at GET /goals?version=<id>, or author under one directly with POST /goals/:id/tasks.',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
168
180
|
if (body.goal_id != null) {
|
|
169
181
|
const goal = await db.getGoal(body.goal_id);
|
|
170
182
|
if (!goal || String(goal.version_id) !== String(body.version_id)) {
|
|
@@ -156,6 +156,10 @@ async function restockTemplate(discipline, template, floors) {
|
|
|
156
156
|
const seq = maxSeq + 1 + i;
|
|
157
157
|
const task = await db.createTask({
|
|
158
158
|
versionId: template.version_id,
|
|
159
|
+
// BV1.R11 (task 1003598): no goal to name until the per-version maintenance
|
|
160
|
+
// goal exists (task 1003605, R18). Declared out loud rather than relying on
|
|
161
|
+
// a silent default — see createTask.
|
|
162
|
+
allowCatchAll: true,
|
|
159
163
|
title: template.title,
|
|
160
164
|
description: buildDescription(template),
|
|
161
165
|
status: 'ready',
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.571",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.571",
|
|
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.
|
|
3
|
+
"version": "1.19.571",
|
|
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.
|
|
52
|
+
const CORE_VERSION = '1.19.571'; // 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,141 @@
|
|
|
1
|
+
// tests/hall_landing_boot.mjs — the front door's boot order (task 1003673).
|
|
2
|
+
//
|
|
3
|
+
// Regression guard for the bug that locked every invite-only instance out of its
|
|
4
|
+
// own first builder: a signed-out visitor to /builders never saw the landing.
|
|
5
|
+
// builders.js mounted the hall widgets BEFORE /me resolved, a widget's own boot
|
|
6
|
+
// read 401'd, that read treated 401 as "go and sign in" and NAVIGATED — so the
|
|
7
|
+
// page bounced to GitHub, which refuses a first-timer with "request access
|
|
8
|
+
// first" and points them back at /builders to be bounced again. The request form
|
|
9
|
+
// sat behind that loop, unreachable, so the approval queue could never fill.
|
|
10
|
+
//
|
|
11
|
+
// The invariant this locks down: AUTH IS RESOLVED BEFORE THE HALL MOUNTS
|
|
12
|
+
// ANYTHING. A signed-out visitor gets the landing and nothing else; only a
|
|
13
|
+
// signed-in one mounts widgets. Executes the real builders.js in a minimal DOM
|
|
14
|
+
// stub (the hall_signout.mjs / hall_nav.mjs pattern) and drives DOMContentLoaded.
|
|
15
|
+
// Pure vm + file-read (no DB/network) — runs in the DB-free unit gate.
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import vm from 'node:vm';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/builders.js'), 'utf8');
|
|
25
|
+
|
|
26
|
+
// Boot builders.js against a /me that answers `meStatus`, then fire
|
|
27
|
+
// DOMContentLoaded and let the microtasks drain. Returns what the page DID.
|
|
28
|
+
async function boot({ meStatus }) {
|
|
29
|
+
const calls = { assign: [], requests: [], rendered: [] };
|
|
30
|
+
|
|
31
|
+
const makeEl = () => ({
|
|
32
|
+
dataset: {}, style: {}, title: '', textContent: '', hidden: false, value: '',
|
|
33
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
34
|
+
setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
|
|
35
|
+
addEventListener() {}, querySelector() { return null; }, querySelectorAll() { return []; },
|
|
36
|
+
appendChild() {}, focus() {}, click() {}, closest() { return null; },
|
|
37
|
+
set innerHTML(_v) {}, get innerHTML() { return ''; },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const location = { pathname: '/builders', search: '', hash: '', href: 'https://x/builders', assign(u) { calls.assign.push(u); } };
|
|
41
|
+
|
|
42
|
+
const api = {
|
|
43
|
+
request(method, p) {
|
|
44
|
+
calls.requests.push(`${method} ${p}`);
|
|
45
|
+
const isMe = /\/me(\?|$)/.test(p);
|
|
46
|
+
const status = isMe ? meStatus : 200;
|
|
47
|
+
if (status === 401) return Promise.resolve({ status: 401, ok: false, data: null });
|
|
48
|
+
// A shape generic enough for the boot path; the renderers are spies below.
|
|
49
|
+
return Promise.resolve({ status: 200, ok: true, data: { builder: { id: '1', rank: 'metic' }, onboarding: null, tasks: [], goals: [], items: [] } });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const spy = (name) => (...args) => { calls.rendered.push(name); return args; };
|
|
54
|
+
const documentObj = {
|
|
55
|
+
body: { dataset: { page: 'home' } },
|
|
56
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
|
|
57
|
+
getElementById() { return makeEl(); },
|
|
58
|
+
querySelector() { return makeEl(); },
|
|
59
|
+
querySelectorAll() { return []; },
|
|
60
|
+
createElement() { return makeEl(); },
|
|
61
|
+
listeners: {},
|
|
62
|
+
addEventListener(type, fn) { (this.listeners[type] || (this.listeners[type] = [])).push(fn); },
|
|
63
|
+
get cookie() { return ''; }, set cookie(_v) {},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const windowObj = {
|
|
67
|
+
__BRANDING__: { identity: { worldName: 'W', productName: 'W' }, currency: { label: 'credits' } },
|
|
68
|
+
__MODULES__: { enabled: {} },
|
|
69
|
+
BongosClient: { createClient: () => api },
|
|
70
|
+
OTB: {
|
|
71
|
+
moduleOn: () => true,
|
|
72
|
+
escapeHtml: (s) => String(s == null ? '' : s),
|
|
73
|
+
$: () => makeEl(),
|
|
74
|
+
fmtNum: (n) => String(n),
|
|
75
|
+
fmtDate: () => '',
|
|
76
|
+
rankLabel: (r) => String(r || '—'),
|
|
77
|
+
toast: () => {},
|
|
78
|
+
hallWidgets: () => [],
|
|
79
|
+
contributeHallWidget: () => {},
|
|
80
|
+
},
|
|
81
|
+
OTBKit: {
|
|
82
|
+
makePager: () => ({ page: 1, perPage: 10 }),
|
|
83
|
+
paginate: (rows) => rows,
|
|
84
|
+
wirePager: () => {},
|
|
85
|
+
},
|
|
86
|
+
OTBHallRender: () => ({
|
|
87
|
+
SETUP_CONFIG_KEY: 'k', analyticsState: {}, lbState: {}, onboardingBox: () => makeEl(),
|
|
88
|
+
shipState: {}, shipsPager: { page: 1 }, welcomeDismissalKey: () => 'k',
|
|
89
|
+
mountHallWidgets: spy('mountHallWidgets'),
|
|
90
|
+
renderLanding: spy('renderLanding'),
|
|
91
|
+
renderCurveBody: spy('renderCurveBody'), renderGreeting: spy('renderGreeting'),
|
|
92
|
+
renderNav: spy('renderNav'), renderOnboarding: spy('renderOnboarding'),
|
|
93
|
+
renderProfile: spy('renderProfile'), renderRecentShips: spy('renderRecentShips'),
|
|
94
|
+
renderWelcomeCore: spy('renderWelcomeCore'),
|
|
95
|
+
}),
|
|
96
|
+
matchMedia: () => ({ matches: false, addEventListener() {} }),
|
|
97
|
+
localStorage: { getItem() { return null; }, setItem() {}, removeItem() {} },
|
|
98
|
+
location,
|
|
99
|
+
addEventListener() {},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const sandbox = {
|
|
103
|
+
window: windowObj, document: documentObj, location, console,
|
|
104
|
+
setTimeout, clearTimeout, setInterval, clearInterval, fetch: () => Promise.reject(new Error('no network in this test')),
|
|
105
|
+
};
|
|
106
|
+
sandbox.globalThis = sandbox;
|
|
107
|
+
vm.createContext(sandbox);
|
|
108
|
+
vm.runInContext(SRC, sandbox, { filename: 'builders.js' });
|
|
109
|
+
|
|
110
|
+
for (const fn of documentObj.listeners.DOMContentLoaded || []) fn();
|
|
111
|
+
// Drain the boot promise chain (a handful of awaits, no timers).
|
|
112
|
+
for (let i = 0; i < 25; i += 1) await Promise.resolve();
|
|
113
|
+
return calls;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
test('a signed-out visitor gets the landing — and is NEVER navigated away from it', async () => {
|
|
117
|
+
const calls = await boot({ meStatus: 401 });
|
|
118
|
+
|
|
119
|
+
assert.deepEqual(calls.assign, [],
|
|
120
|
+
'the signed-out front door navigated somewhere. That is the dead end: GitHub refuses a '
|
|
121
|
+
+ 'first-timer and sends them back here, so the request-access form is unreachable and '
|
|
122
|
+
+ 'an invite-only instance can never admit anyone.');
|
|
123
|
+
assert.ok(calls.rendered.includes('renderLanding'), 'the landing (sign in / request access) never rendered');
|
|
124
|
+
assert.ok(!calls.rendered.includes('mountHallWidgets'),
|
|
125
|
+
'widgets mounted for a signed-out visitor — each one boots its own authenticated read, '
|
|
126
|
+
+ 'and any of those may navigate. The landing must be the whole page.');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('auth is resolved BEFORE anything else is asked for', async () => {
|
|
130
|
+
const calls = await boot({ meStatus: 401 });
|
|
131
|
+
assert.equal(calls.requests.length, 1, `the page asked for more than /me while signed out: ${calls.requests.join(', ')}`);
|
|
132
|
+
assert.match(calls.requests[0], /\/me$/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a signed-in builder still gets the hall', async () => {
|
|
136
|
+
const calls = await boot({ meStatus: 200 });
|
|
137
|
+
assert.ok(calls.rendered.includes('mountHallWidgets'), 'the hall did not mount for a signed-in builder');
|
|
138
|
+
assert.deepEqual(calls.assign, [], 'a signed-in boot navigated away');
|
|
139
|
+
assert.equal(calls.requests.filter((r) => /\/me$/.test(r)).length, 1,
|
|
140
|
+
'boot asked for /me more than once — the resolved payload is handed to loadAll, not re-fetched');
|
|
141
|
+
});
|
package/tests/reward_gate.mjs
CHANGED
|
@@ -210,7 +210,7 @@ async function withStubbedCreatePool(run) {
|
|
|
210
210
|
|
|
211
211
|
t('a task CREATED with NO reward is born with a positive, capped value', async () => {
|
|
212
212
|
await withStubbedCreatePool(async (captured) => {
|
|
213
|
-
const row = await db.createTask({ versionId: 1, title: 't', kind: 'feature', estMinutes: 40 });
|
|
213
|
+
const row = await db.createTask({ versionId: 1, goalId: 77, title: 't', kind: 'feature', estMinutes: 40 });
|
|
214
214
|
assert.ok(captured.creditsReward > 0, 'INSERT must bind a positive credits_reward');
|
|
215
215
|
assert.equal(captured.creditsReward, 40, 'feature @40min -> 40 drachmae');
|
|
216
216
|
assert.ok(captured.creditsReward <= db.REWARD_SUGGESTION_CAP, 'must respect the cap');
|
|
@@ -220,7 +220,7 @@ t('a task CREATED with NO reward is born with a positive, capped value', async (
|
|
|
220
220
|
|
|
221
221
|
t('a task CREATED with credits_reward 0 (the route default) is auto-assigned', async () => {
|
|
222
222
|
await withStubbedCreatePool(async (captured) => {
|
|
223
|
-
await db.createTask({ versionId: 1, title: 't', creditsReward: 0, kind: 'bug', estMinutes: 40 });
|
|
223
|
+
await db.createTask({ versionId: 1, goalId: 77, title: 't', creditsReward: 0, kind: 'bug', estMinutes: 40 });
|
|
224
224
|
// bug weight 1.25 -> 50; 0 is treated as "unset" so the default kicks in.
|
|
225
225
|
assert.equal(captured.creditsReward, 50);
|
|
226
226
|
});
|
|
@@ -228,14 +228,14 @@ t('a task CREATED with credits_reward 0 (the route default) is auto-assigned', a
|
|
|
228
228
|
|
|
229
229
|
t('a huge-est new task is CAPPED at creation, not ballooned', async () => {
|
|
230
230
|
await withStubbedCreatePool(async (captured) => {
|
|
231
|
-
await db.createTask({ versionId: 1, title: 't', kind: 'feature', estMinutes: 9999 });
|
|
231
|
+
await db.createTask({ versionId: 1, goalId: 77, title: 't', kind: 'feature', estMinutes: 9999 });
|
|
232
232
|
assert.equal(captured.creditsReward, db.REWARD_SUGGESTION_CAP);
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
235
|
|
|
236
236
|
t('an EXPLICIT positive credits_reward is PRESERVED (never overridden)', async () => {
|
|
237
237
|
await withStubbedCreatePool(async (captured) => {
|
|
238
|
-
await db.createTask({ versionId: 1, title: 't', creditsReward: 17, kind: 'feature', estMinutes: 40 });
|
|
238
|
+
await db.createTask({ versionId: 1, goalId: 77, title: 't', creditsReward: 17, kind: 'feature', estMinutes: 40 });
|
|
239
239
|
assert.equal(captured.creditsReward, 17, 'author-set positive value must survive untouched');
|
|
240
240
|
});
|
|
241
241
|
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// tests/task_goal_required.mjs — a goal-less create is no longer SILENT
|
|
2
|
+
// (BV1.R11, task 1003598, goal 1000086, ADR 0250 D4).
|
|
3
|
+
//
|
|
4
|
+
// WHAT WAS ACTUALLY WRONG. Task 1763 made `createTask` default any create with no
|
|
5
|
+
// `goalId` into its version's catch-all "<version> — general" goal, at the lowest
|
|
6
|
+
// layer, so that no caller had to think about it. The invariant "every task
|
|
7
|
+
// belongs to a goal" then held — technically — while 53 open tasks accumulated in
|
|
8
|
+
// two buckets nobody reads. Unscoped work never snuck past the rule; the rule
|
|
9
|
+
// filed it out of sight.
|
|
10
|
+
//
|
|
11
|
+
// So the defect is the SILENCE, not the bucket. A caller now either names a goal
|
|
12
|
+
// or says out loud that it cannot, by passing `allowCatchAll: true`: same landing
|
|
13
|
+
// place, same never-break-a-creator promise, but the exceptions are a one-command
|
|
14
|
+
// grep instead of an absence, and a create vector added next year gets a thrown
|
|
15
|
+
// error at its first test run rather than a quiet home in a bucket.
|
|
16
|
+
//
|
|
17
|
+
// NOT FULLY DELETED, and that is a recorded decision rather than an oversight.
|
|
18
|
+
// R11 literally asks for the fallback's deletion, but two in-repo vectors have no
|
|
19
|
+
// goal to name yet — `newcomer-restock` (the beginner queue) and `copy-desk`
|
|
20
|
+
// (copy proposals) — whose honest home is the per-version maintenance goal, task
|
|
21
|
+
// 1003605 (R18), still behind R06 and R12. Deleting today would drain the
|
|
22
|
+
// newcomer queue. The owner chose to remove the silent half now and leave the
|
|
23
|
+
// true deletion to R18. This file pins the opt-in roster so it cannot grow
|
|
24
|
+
// quietly in the meantime.
|
|
25
|
+
//
|
|
26
|
+
// Run: node --test tests/task_goal_required.mjs
|
|
27
|
+
|
|
28
|
+
import assert from 'node:assert/strict';
|
|
29
|
+
import { test } from 'node:test';
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
|
|
33
|
+
process.env.NODE_ENV = 'test';
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
const { createTask } = require('../modules/lifecycle/db.js');
|
|
36
|
+
|
|
37
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
38
|
+
|
|
39
|
+
// A pool that would happily create the row, so a rejection can only come from the
|
|
40
|
+
// guard and never from a database that was never reached.
|
|
41
|
+
const permissivePool = {
|
|
42
|
+
query: async () => ({ rows: [{ id: '1', goal_id: null, title: 'x' }] }),
|
|
43
|
+
connect: async () => ({ query: async () => ({ rows: [] }), release() {} }),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ---- the guard --------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
test('a create with no goalId THROWS instead of quietly filing into a bucket', async () => {
|
|
49
|
+
await assert.rejects(
|
|
50
|
+
() => createTask({ versionId: 'BONGOS-V1', title: 't' }, { pool: permissivePool }),
|
|
51
|
+
/goalId is required/,
|
|
52
|
+
'the old behaviour was to succeed and say nothing');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('the error tells the caller both ways out', async () => {
|
|
56
|
+
const err = await createTask({ versionId: 'BONGOS-V1', title: 't' }, { pool: permissivePool })
|
|
57
|
+
.then(() => null, (e) => e);
|
|
58
|
+
assert.ok(err, 'must reject');
|
|
59
|
+
assert.match(err.message, /BV1\.R11/, 'names the rule, so the reason is findable');
|
|
60
|
+
assert.match(err.message, /allowCatchAll: true/, 'and the escape hatch, spelled exactly');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('only a literal true opts in — a truthy value still throws', async () => {
|
|
64
|
+
for (const v of [1, 'yes', {}, 'true']) {
|
|
65
|
+
await assert.rejects(
|
|
66
|
+
() => createTask({ versionId: 'BONGOS-V1', title: 't', allowCatchAll: v }, { pool: permissivePool }),
|
|
67
|
+
/goalId is required/,
|
|
68
|
+
`${JSON.stringify(v)} must not open the hatch`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('naming a goal needs no flag at all', async () => {
|
|
73
|
+
// The ordinary path must stay ordinary: a caller with a goal never learns this
|
|
74
|
+
// parameter exists.
|
|
75
|
+
await createTask(
|
|
76
|
+
{ versionId: 'BONGOS-V1', title: 't', goalId: 77 },
|
|
77
|
+
{ pool: permissivePool, client: permissivePool },
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ---- the route --------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
const WRITE = src('modules/lifecycle/routes/task-write-routes.js');
|
|
84
|
+
const POST_TASKS = (() => {
|
|
85
|
+
const a = WRITE.indexOf("router.post('/tasks'");
|
|
86
|
+
assert.notEqual(a, -1);
|
|
87
|
+
return WRITE.slice(a, WRITE.indexOf("router.patch('/tasks/:id'"));
|
|
88
|
+
})();
|
|
89
|
+
|
|
90
|
+
test('POST /tasks refuses a body with no goal_id, as a 400 that says where to look', () => {
|
|
91
|
+
assert.match(POST_TASKS, /if \(body\.goal_id == null\) \{/);
|
|
92
|
+
assert.match(POST_TASKS, /res\.fail\('goal_id_required', \{[\s\S]*?status: 400/,
|
|
93
|
+
'a missing required field is the caller\'s mistake, not a conflict');
|
|
94
|
+
assert.match(POST_TASKS, /GET \/goals\?version=/,
|
|
95
|
+
'a refusal that does not say how to comply gets worked around');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the route refuses BEFORE it validates the goal it was not given', () => {
|
|
99
|
+
const required = POST_TASKS.indexOf("'goal_id_required'");
|
|
100
|
+
const validate = POST_TASKS.indexOf("'bad_goal_id'");
|
|
101
|
+
const create = POST_TASKS.indexOf('await db.createTask(');
|
|
102
|
+
assert.ok(required !== -1 && validate !== -1 && create !== -1);
|
|
103
|
+
assert.ok(required < validate, 'absent and wrong are different answers');
|
|
104
|
+
assert.ok(required < create);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('POST /tasks does NOT pass the escape hatch — this door always has a goal', () => {
|
|
108
|
+
assert.equal(/allowCatchAll/.test(POST_TASKS), false,
|
|
109
|
+
'/builder-start lists work by goal; the human/agent vector can always name one');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ---- the opt-in roster, pinned ----------------------------------------------
|
|
113
|
+
|
|
114
|
+
const OPT_INS = [
|
|
115
|
+
['modules/onboarding/newcomer-restock.js', 'the beginner queue, stamped from templates'],
|
|
116
|
+
['modules/copy-desk/routes/copy-desk.js', 'copy-change proposals'],
|
|
117
|
+
['modules/lifecycle/kickoff-seed.js', "the founder's first board, on an instance with no goals at all"],
|
|
118
|
+
['modules/lifecycle/cascade.js', 'a follow-up off a parent task that is itself goal-less'],
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
test('exactly four vectors opt in, and each says why', () => {
|
|
122
|
+
for (const [file, why] of OPT_INS) {
|
|
123
|
+
const body = src(file);
|
|
124
|
+
assert.match(body, /allowCatchAll/, `${file} should opt in (${why})`);
|
|
125
|
+
assert.match(body, /BV1\.R11|1003605|R18/,
|
|
126
|
+
`${file} must explain itself — an undocumented opt-in is the silence again`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('cascade opts in ONLY when inheritance actually failed', () => {
|
|
131
|
+
// The others opt in unconditionally because they have no goal to name at all.
|
|
132
|
+
// A cascade normally inherits its parent's goal and must keep doing so — the
|
|
133
|
+
// hatch is for a goal-less PARENT (a legacy catch-all row), not for cascades in
|
|
134
|
+
// general. A blanket `true` here would quietly re-open the hole for every
|
|
135
|
+
// follow-up task the system files.
|
|
136
|
+
const body = src('modules/lifecycle/cascade.js');
|
|
137
|
+
assert.match(body, /allowCatchAll: task\.goal_id == null,/);
|
|
138
|
+
assert.equal(/allowCatchAll: true/.test(body), false,
|
|
139
|
+
'an unconditional hatch here would cover every cascade, not just the legacy ones');
|
|
140
|
+
assert.match(body, /goalId: task\.goal_id != null \? Number\(task\.goal_id\) : null,/,
|
|
141
|
+
'inheritance stays the primary path');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a goal-less parent does not silently lose its cascade task', async () => {
|
|
145
|
+
// The regression this pair of fixes closes: under the guard, a null goalId
|
|
146
|
+
// throws; the throw is caught per-rule so the ship is safe, and the cascade
|
|
147
|
+
// task would have vanished with only a console line — a silent goal-less gap,
|
|
148
|
+
// which is the exact thing this rule exists to close.
|
|
149
|
+
const { runCascadesForShip } = require('../modules/lifecycle/cascade.js');
|
|
150
|
+
const specs = [];
|
|
151
|
+
const report = await runCascadesForShip({ id: 5 }, {
|
|
152
|
+
getTask: async () => ({
|
|
153
|
+
id: 5, version_id: 'BONGOS-V1', goal_id: null, status: 'shipped',
|
|
154
|
+
discipline: 'artist', kind: 'feature', title: 'legacy', touches: [],
|
|
155
|
+
}),
|
|
156
|
+
findTaskBySourceRef: async () => null,
|
|
157
|
+
createTask: async (spec) => { specs.push(spec); return { id: 9 }; },
|
|
158
|
+
});
|
|
159
|
+
assert.deepEqual(report.errors, [], 'no rule may error on a goal-less parent');
|
|
160
|
+
for (const spec of specs) {
|
|
161
|
+
assert.equal(spec.goalId, null, 'nothing to inherit');
|
|
162
|
+
assert.equal(spec.allowCatchAll, true, 'so it declares the catch-all rather than throwing');
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('a cascade off a NORMAL parent inherits and does not open the hatch', async () => {
|
|
167
|
+
const { runCascadesForShip } = require('../modules/lifecycle/cascade.js');
|
|
168
|
+
const specs = [];
|
|
169
|
+
await runCascadesForShip({ id: 5 }, {
|
|
170
|
+
getTask: async () => ({
|
|
171
|
+
id: 5, version_id: 'BONGOS-V1', goal_id: 77, status: 'shipped',
|
|
172
|
+
discipline: 'artist', kind: 'feature', title: 'normal', touches: [],
|
|
173
|
+
}),
|
|
174
|
+
findTaskBySourceRef: async () => null,
|
|
175
|
+
createTask: async (spec) => { specs.push(spec); return { id: 9 }; },
|
|
176
|
+
});
|
|
177
|
+
for (const spec of specs) {
|
|
178
|
+
assert.equal(spec.goalId, 77, 'the follow-up belongs where the work belongs');
|
|
179
|
+
assert.equal(spec.allowCatchAll, false, 'and the hatch stays shut');
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('no FIFTH vector has appeared', () => {
|
|
184
|
+
// The roster is the point: if this fails, either a new create vector took the
|
|
185
|
+
// easy way out, or R18 landed and one of these can now name a real goal — in
|
|
186
|
+
// which case delete its opt-in and this line, do not extend the list.
|
|
187
|
+
const roster = new Set([...OPT_INS.map(([f]) => f), 'modules/lifecycle/db-tasks.js']);
|
|
188
|
+
const suspects = [
|
|
189
|
+
'modules/ideas/inbox.js', 'modules/lifecycle/routes/goals.js',
|
|
190
|
+
'modules/lifecycle/routes/task-write-routes.js',
|
|
191
|
+
];
|
|
192
|
+
for (const f of suspects) {
|
|
193
|
+
if (roster.has(f)) continue;
|
|
194
|
+
assert.equal(/allowCatchAll/.test(src(f)), false,
|
|
195
|
+
`${f} must name a goal rather than opting out`);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
test('the vectors that CAN name a goal still do', () => {
|
|
199
|
+
assert.match(src('modules/ideas/inbox.js'), /goalId: goal\.id/,
|
|
200
|
+
'idea routing names the goal it routed to');
|
|
201
|
+
assert.match(src('modules/lifecycle/routes/goals.js'), /db\.createTask\(\{/,
|
|
202
|
+
'and the goal-authored vector has stamped its goal since BV1.R60');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ---- the fallback itself ----------------------------------------------------
|
|
206
|
+
|
|
207
|
+
test('the catch-all resolver survives, reachable only through the declared hatch', () => {
|
|
208
|
+
const dbTasks = src('modules/lifecycle/db-tasks.js');
|
|
209
|
+
assert.match(dbTasks, /generalGoalIdForVersion\(versionId\)/,
|
|
210
|
+
'R18 is when this goes; deleting it today would drain the newcomer queue');
|
|
211
|
+
const guard = dbTasks.slice(dbTasks.indexOf('let goalIdEffective = goalId;'));
|
|
212
|
+
const hatch = guard.indexOf('allowCatchAll !== true');
|
|
213
|
+
const resolve = guard.indexOf('generalGoalIdForVersion(versionId)');
|
|
214
|
+
assert.ok(hatch !== -1 && hatch < resolve,
|
|
215
|
+
'the throw comes first — the resolver is unreachable without the flag');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('the remaining gap is written down where the next reader will stand', () => {
|
|
219
|
+
const dbTasks = src('modules/lifecycle/db-tasks.js');
|
|
220
|
+
assert.match(dbTasks, /WHY NOT DELETE THE FALLBACK OUTRIGHT/,
|
|
221
|
+
'so the partial job reads as a decision, not an unfinished edit');
|
|
222
|
+
assert.match(dbTasks, /1003605/, 'and names the task that finishes it');
|
|
223
|
+
});
|