@bongos/core 1.24.93 → 1.24.94

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/bongos/package-core.js",
5
- "core_version": "1.24.93",
6
- "core_contract": "1.24.93",
7
- "source_commit": "8071fd2502f713c6b549097cfd5d79d606f32867",
5
+ "core_version": "1.24.94",
6
+ "core_contract": "1.24.94",
7
+ "source_commit": "6a898f9254ca27fd22797dec8c2f6c309e491bff",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-11-12T20:21:01.886Z",
9
+ "built_at": "2026-11-12T20:40:39.447Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 650,
12
+ "docs_redacted": 651,
13
13
  "agent_docs_stubbed": 32,
14
- "functional_verbatim": 3967,
14
+ "functional_verbatim": 3968,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 4650,
20
- "tree_sha256": "14cce51acfe462e49a4caaa95f39316299e9da4b7eaad55df49bd1082d03adaf",
19
+ "file_count": 4652,
20
+ "tree_sha256": "6dc7ece8c2d5151842e7d7b02d1a13e17634fc2e8a67c3d9c2a4a295d4f42d8c",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2479,10 +2479,15 @@
2479
2479
  "mode": "0000644",
2480
2480
  "sha256": "d98e0ea9a8a9b541bb58361bb124a4884c8165758aa8e3243e743368467e79ed"
2481
2481
  },
2482
+ {
2483
+ "path": "docs/adr/0404-a-party-is-a-room-and-rooms-register-as-kinds.md",
2484
+ "mode": "0000644",
2485
+ "sha256": "b383c189cf0069b724ee895d984592ec5bc04aded9deef32ea7129f7ce3381da"
2486
+ },
2482
2487
  {
2483
2488
  "path": "docs/adr/README.md",
2484
2489
  "mode": "0000644",
2485
- "sha256": "ca848f718bbc048e492417b7b92b00359d691473891950302d5d7d232da2a1ad"
2490
+ "sha256": "cf5bfe36cd688b813a79d08b7020ff32323dd77edb1bcdbc8a9e70ca1cae02b6"
2486
2491
  },
2487
2492
  {
2488
2493
  "path": "docs/api-reference.md",
@@ -3037,12 +3042,12 @@
3037
3042
  {
3038
3043
  "path": "docs/module-api-changelog.md",
3039
3044
  "mode": "0000644",
3040
- "sha256": "bba6458b2c8742106eabe1e8c4646fb9a5ceea75fba0bb3a375b6ea7a986fc80"
3045
+ "sha256": "cb17eae6ff2bd4b1a362a0b1040306eb251eaf7bcd94926b4c1055f850e762e0"
3041
3046
  },
3042
3047
  {
3043
3048
  "path": "docs/modules-contract.md",
3044
3049
  "mode": "0000644",
3045
- "sha256": "d537c6d220e7ba90e46a283c6bf2f0e38699478ff0be33271eeb4e12bd9651c5"
3050
+ "sha256": "612555c173a834511820e075ec61e13b5e89494185ac85d84d6e57eed58dc0a5"
3046
3051
  },
3047
3052
  {
3048
3053
  "path": "docs/onboarding/diagrams/01-task-lifecycle.mmd",
@@ -3122,7 +3127,7 @@
3122
3127
  {
3123
3128
  "path": "docs/page-readings.json",
3124
3129
  "mode": "0000644",
3125
- "sha256": "a15df86b33d43c4b42bc9854319497d86b3671ffbda88f765bfe8ed3a9ed6fde"
3130
+ "sha256": "82aaee617c4aa382cd3dfc4c5c0d8a36fa6e25c78b1fe0466c5000f40936fe9f"
3126
3131
  },
3127
3132
  {
3128
3133
  "path": "docs/project-context.template.md",
@@ -6647,17 +6652,17 @@
6647
6652
  {
6648
6653
  "path": "modules/hall-ui/public/home-city.js",
6649
6654
  "mode": "0000644",
6650
- "sha256": "438c26d63fdc11c89c219e317ded486217275fc48e3c3746f76b9565c11342a7"
6655
+ "sha256": "9a8a5b5eeae1af57285b0f352e6c01a721ea2d0b5a0310e3efd7197344eb5d05"
6651
6656
  },
6652
6657
  {
6653
6658
  "path": "modules/hall-ui/public/home-gallery.css",
6654
6659
  "mode": "0000644",
6655
- "sha256": "416059288af3503f90a6aff4ed2b8b1ce94741cd975fbc5bed3838c4d69a632c"
6660
+ "sha256": "416a9e3abf34f48f0037350cb43f9b795197ae9e004aa2b0baeeb55b89e959de"
6656
6661
  },
6657
6662
  {
6658
6663
  "path": "modules/hall-ui/public/home-gallery.js",
6659
6664
  "mode": "0000644",
6660
- "sha256": "aed701de3b07999ef86c7e5b93ac294b1e413ada1230fbb909049e8aa6bd23d0"
6665
+ "sha256": "6e56d2f9a5cbb9248e15d4a135756ceaec1b1f186c635e2f21ca091a730d964d"
6661
6666
  },
6662
6667
  {
6663
6668
  "path": "modules/hall-ui/public/home-idea-box.css",
@@ -7757,7 +7762,7 @@
7757
7762
  {
7758
7763
  "path": "modules/hall-ui/records/visor.md",
7759
7764
  "mode": "0000644",
7760
- "sha256": "a1e1421463d9107782c46309339510d6e5c82a38f142ccf35f17fb53effb6c97"
7765
+ "sha256": "1ff5b06dcefb1effa6e73de9b398a100b8653e1f4f94b60a6aca87f4b9033a9e"
7761
7766
  },
7762
7767
  {
7763
7768
  "path": "modules/hall-ui/records/welcome-tower.md",
@@ -12107,12 +12112,12 @@
12107
12112
  {
12108
12113
  "path": "package-lock.json",
12109
12114
  "mode": "0000644",
12110
- "sha256": "aca584f965a5191a12f9c0547ed3bc0be72a13f8043b13a2d3a2ec65b08d8632"
12115
+ "sha256": "a7d4b6e236dd9fa054bf6ba03cc20cc48a78c090982223f80563f98cc07fd949"
12111
12116
  },
12112
12117
  {
12113
12118
  "path": "package.json",
12114
12119
  "mode": "0000644",
12115
- "sha256": "c0cae817310174c3e98cef573263c33c9807c1c8086b050fc13f5f1a18001b4d"
12120
+ "sha256": "a2ce5ef571d953e85a478027aba5552ee7995b53be50fdd9a8f06eccf972fd43"
12116
12121
  },
12117
12122
  {
12118
12123
  "path": "public-docs/index.html",
@@ -12132,7 +12137,7 @@
12132
12137
  {
12133
12138
  "path": "release-notes.json",
12134
12139
  "mode": "0000644",
12135
- "sha256": "6aff28036a7071c6895abc1f682477f4cd41b1fe02bf96fad15a7f42c05de7d9"
12140
+ "sha256": "7db7b72229854c69c6dc688f9b1f9ea60292e892bb4df79cbf9a8dec9cfee379"
12136
12141
  },
12137
12142
  {
12138
12143
  "path": "scripts/bongos/CLAUDE.md",
@@ -16137,7 +16142,7 @@
16137
16142
  {
16138
16143
  "path": "src/module-api.js",
16139
16144
  "mode": "0000644",
16140
- "sha256": "a8399bb92d13d12c44c07cf942398f10b93590bbeb054610a5d9952cd9b044ab"
16145
+ "sha256": "7fb7ef1aeae573ed959000ac2876a210b4f0607e7d231a65e33516c6ccbd1acc"
16141
16146
  },
16142
16147
  {
16143
16148
  "path": "src/module-loader/catalog.js",
@@ -16172,7 +16177,7 @@
16172
16177
  {
16173
16178
  "path": "src/module-seams.js",
16174
16179
  "mode": "0000644",
16175
- "sha256": "e3cacfd18defeb53108e2696a14efba7012d052899620deccd4e08248f618bd2"
16180
+ "sha256": "7975a911a4bf1f42eb3fecbf5b2d4bb0d62716a58bc6ee442f71a5a155f0cf76"
16176
16181
  },
16177
16182
  {
16178
16183
  "path": "src/modules.js",
@@ -19462,7 +19467,7 @@
19462
19467
  {
19463
19468
  "path": "tests/home_gallery_widget.mjs",
19464
19469
  "mode": "0000644",
19465
- "sha256": "c6efbc12c975d3a6ca63219dcdd3748b79d7365097d9fb8881503c9769a1db6a"
19470
+ "sha256": "60e0615ea852127d3aedfba058e50ab56957e314bd56a32dd99a8df5326662cc"
19466
19471
  },
19467
19472
  {
19468
19473
  "path": "tests/home_idea_box.mjs",
@@ -21849,6 +21854,11 @@
21849
21854
  "mode": "0000644",
21850
21855
  "sha256": "1e565de6c7587bd32d3e8c8fba7ad245776285f3a882c752e7e4ca7cfacbd063"
21851
21856
  },
21857
+ {
21858
+ "path": "tests/room_kinds_seam.mjs",
21859
+ "mode": "0000644",
21860
+ "sha256": "0bdb24f1af191a0f9b0aad33f5e8de6d01c48c9affc101049c856df5866a892e"
21861
+ },
21852
21862
  {
21853
21863
  "path": "tests/roster_government_columns.mjs",
21854
21864
  "mode": "0000644",
@@ -0,0 +1,118 @@
1
+ # ADR 0404 — A party is a room, and rooms register as kinds
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-11-12
5
+ - **Task:** [task 1005220](https://cloudbongos.com/builders#/task/1005220) — goal 1000148 "A party talks through Discord" (BONGOS-V3, criterion `a-party-talks-through-discord`)
6
+ - **Deciders:** the owner, Lars, 2026-11-12: free-form parties are in V3 as well as the game's existing groups; the chat bridge to Discord comes after the core; the party bars show in voice / muted, not a live speaker. The plan behind this ADR went through two rounds of adversarial review the same day.
7
+ - **Builds on:** [ADR 0400](0400-a-chain-reserves-work-and-each-sub-quest-is-still-claimed-alone.md) (`chain_party`), [ADR 0403](0403-in-game-mode-the-visor-is-the-control-and-the-scene-is-the-view.md) (controls live on the visor), [ADR 0083](0083-modular-architecture-strangler-build-plan.md) (modules meet only through seams), [ADR 0016](0016-trust-boundary-server-enforced-permissions.md) (rank is the only authority).
8
+ - **Gates:** every task in goal 1000148. Its sibling, the ADR on Discord room channels and chat crossing (task 1005221), decides the Discord side and amends ADR 0032, 0033 and 0037.
9
+
10
+ ## Context
11
+
12
+ The owner asked for Discord text and voice in the game "to begin party dynamics". The game already has several groups that would talk together, each planned in a different module:
13
+
14
+ - a **chain party** (`chain_party`, ADR 0400): the people holding a quest chain, with a raid quorum;
15
+ - a **scoping session** (task 1005164): a Patron's room of up to about forty;
16
+ - a **decision room** (task 1005173): a presenter, the people in the room, and pre-voters outside it;
17
+ - and, by the owner's decision, a **free-form party** a player forms on their own.
18
+
19
+ Each needs the same three things: a private Discord voice channel, a list of who is in it and whether they are in voice, and a chat. Building those three things once per group would mean four copies. Building them in a new module that reads each group's tables would break the module boundary (a module may require only `src/module-api.js`, never another module).
20
+
21
+ The obvious seam does not work. A port is single-provider: `registerProvider` throws on a second provider (`src/module-seams.js`) and `verifyPortsSatisfied` fails the boot when two enabled modules declare the same port. Four modules cannot all provide `room.members`.
22
+
23
+ ## Decision
24
+
25
+ ### D1 — A room is any group that talks together; a party is a room
26
+
27
+ "Room" is the one word for a group with a voice channel, a member list and a chat. A party in the player's sense is any room they are in. There are four **kinds** in V3, each owned by the module that already owns the group:
28
+
29
+ | Kind | What it is | Owner module | Cap |
30
+ |---|---|---|---|
31
+ | `party` | a free-form party a player forms (tasks 1005233–1005236) | `lifecycle` | 8 |
32
+ | `chain` | a chain party, an open `chain_party` row (ADR 0400) | `lifecycle` | the largest raid R21 allows; 8 until R21 sets one |
33
+ | `scoping` | a scoping session's participants (task 1005164, which had no module: it is assigned `lifecycle`, because a scope ends as goals and tasks) | `lifecycle` | 40 |
34
+ | `decision` | a decision room's attendees (task 1005173); pre-voters are not members | `government` | 40 |
35
+
36
+ A room's cap is also the `user_limit` of its Discord voice channel.
37
+
38
+ ### D2 — Rooms register through a contribution point, not a port
39
+
40
+ Each owner module contributes its kind at mount time:
41
+
42
+ ```js
43
+ api.contribute('room.kinds', {
44
+ kind: 'scoping', // unique across kinds; a duplicate is a boot error
45
+ owner: 'lifecycle', // the contributing module's key
46
+ cap: 40,
47
+ members: async (roomId) => [{ builderId, joinedAt }], // current members only
48
+ isMember: async (roomId, builderId) => true | false,
49
+ invite: async (roomId, inviterId, inviteeLogin) => {...}, // optional
50
+ accept: async (inviteId, builderId) => {...}, // optional
51
+ });
52
+ ```
53
+
54
+ A consumer (the Discord side, the chat, the party bars) finds a kind with `api.contributions('room.kinds')` and calls its functions. It never reads the owner's tables. A contribution point accepts many contributors, so four owners cause no conflict, and an instance with none of them simply has no rooms.
55
+
56
+ **Two kinds with the same `kind` value is a configuration error.** Consumers look a kind up by its name and must never have to choose between two. The room channels task (task 1005225), the first consumer, adds the boot check that refuses it, the same way a doubly-provided port is refused.
57
+
58
+ The owner fires four events through the normal event seam, each with plain ids and nothing else:
59
+
60
+ | Event | Payload |
61
+ |---|---|
62
+ | `room.opened` | `{ kind, roomId }` |
63
+ | `room.member.joined` | `{ kind, roomId, builderId }` |
64
+ | `room.member.left` | `{ kind, roomId, builderId, reason }`, where reason is `left`, `kicked`, `lapsed` or `deactivated` |
65
+ | `room.closed` | `{ kind, roomId }` |
66
+
67
+ Listeners must also be correct when they miss an event (the bot was down, the process restarted): `members()` is the truth, and every consumer that keeps its own state reconciles against it.
68
+
69
+ ### D3 — Free-form party rules
70
+
71
+ - One open party per builder at a time.
72
+ - The creator leads. When the leader leaves, the longest-standing member leads. When the last member leaves, the party disbands and fires `room.closed`.
73
+ - A party lasts until it is disbanded. It does not end when someone logs out.
74
+ - Only the leader removes a member.
75
+ - Invites go through `invite`/`accept`, with the abuse controls task 1005231 builds: a per-inviter rate limit, a cap on pending invites, silent decline, a cooldown before re-inviting, an invitee-side block, and a switch to refuse all invites.
76
+
77
+ ### D4 — A chain party's voice follows its reservation
78
+
79
+ A member of a chain room is a builder with an open `chain_party` row. This closes ADR 0400's open question 3 *for voice and chat*: a member who leaves the chain loses the voice channel and the chat at once. What the chain's remaining sub-quests do when a raid member leaves stays the chain's own question, settled where the leave door is built (task 1005238).
80
+
81
+ ### D5 — Players without a linked Discord account
82
+
83
+ They get the whole room except voice: they are members, they appear on the party bars marked **not linked**, and they can chat in the game. They are never sent a channel id or link.
84
+
85
+ ### D6 — Realtime is polling in V3
86
+
87
+ The live stream (`GET /api/bongos/live`) sends one empty nudge to every open hall, and modules cannot add their own. Pushing every chat line or voice change through it would make every builder's hall reload everything. V3 polls instead:
88
+
89
+ - **party bars:** every 3 seconds while visible;
90
+ - **chat:** every 3 seconds while the drawer is open;
91
+ - both send a cursor (`?since=`) and get **304** when nothing changed;
92
+ - both stop when the tab is hidden and slow to 15 seconds after 60 seconds idle;
93
+ - both read through a limiter keyed by the builder, tagged with a `meteredScope` like every per-surface limiter, never the shared per-IP default.
94
+
95
+ A scoped live event is the upgrade path if 40-person rooms make polling too costly. It is a core change and is not decided here.
96
+
97
+ ### D7 — With the discord module off
98
+
99
+ No Voice key and no voice state anywhere. Nothing is drawn empty (the visor rule). Rooms, members, invites, the party bars and chat all still work.
100
+
101
+ ### D8 — A room is never authority
102
+
103
+ Being in a room, leading one or inviting into one admits no claim, vote, approval, deploy or spend that rank would refuse (criterion C3, ADR 0016). Every write route the room tasks add is rank-gated like any other.
104
+
105
+ ## Consequences
106
+
107
+ - Goal 1000148's tasks build to this: the room channels (task 1005225) and access sync (task 1005226) read `members()` and listen for the four events; voice presence (task 1005227) and the party bars (task 1005205) check `isMember`; the room chat (task 1005228) keys its lines by `(kind, roomId)`; each kind's task contributes its entry.
108
+ - The `room.kinds` point and the four events are added to the live seam registry in [`docs/modules-contract.md`](../modules-contract.md), and `src/module-seams.js` lists the point among its canonical extension points.
109
+ - `tests/room_kinds_seam.mjs` pins the seam choice: two kinds from two modules register and the boot check still passes, while the same pair registered as a port is refused.
110
+
111
+ ## Rejected
112
+
113
+ - **One `room.members` port.** Ports are single-provider. Four owners would fail the boot.
114
+ - **One port per kind** (`room.members.chain`, `room.members.scoping`, …). It works, but every consumer must hard-code the list of kinds, and a new kind means editing every consumer.
115
+ - **A new `rooms` module that reads each owner's tables.** A module may not require another module, and the rooms module would end up copying each owner's rules about who counts as a member.
116
+ - **A free-form party as a second roster beside `chain_party`, kept in sync.** Two rosters drift. A free-form party is its own kind; when a party takes a chain, its members join through the chain's own door (task 1005160, R20) and the chain room is a separate room.
117
+ - **Push through `/live` now.** It reloads every hall for every message (D6).
118
+ - **Speaking indicators.** Discord gives a web page no "speaking" signal without a partner-only permission. The bars show in voice and muted only, by owner decision.
@@ -519,3 +519,4 @@ These 20 numbers are each shared by exactly two files. They are **accepted histo
519
519
  | 0400 | [**A chain reserves work; each sub-quest is still claimed on its own** ([task 1005158](https://cloudbongos.com/builders#/task/1005158), spike V3G.R18, goal 1000144). C5 of the game conversion: work is claimed as a chain, not picked from a flat table. Owner ruling (interview answer 3): a chain RESERVES, tasks are still claimed one at a time. **Decision:** three new tables (`chains` inside one goal, `chain_tasks` with a task in at most one open chain, `chain_party`), none of them a claim — no `claims` row, no session binding, no worktree — so `gateOneActiveClaimPerSession` (ADR 0097) and its unique index never see a chain. **Order is derived, never stored** (ADR 0153's rejection holds): the topological order of `task_dependencies` over the chain's members, ties by priority/estimate/id — `sequence.js`'s `topoOrder`, moved into one shared module. Every sub-quest claims through the unchanged `POST /claims` ladder. **The one addition** is a narrowing-only `CHAIN_RESERVED` check placed AFTER the last existing rung on both insert sites + the dry run + the feed: an outsider can't claim a reserved chain's sub-quest, while every existing refusal keeps its code, message and precedence. Party size (raid quorum vs routine) is refused at the chain's own join door, never in the claim ladder; character counts seats and is never read by a claim rung. A reservation lapses after days with no claim or ship, reported before it acts (ADR 0226). Rejected: a chain as N claims, loosening the session guard, chain = goal, a stored order, an early-ladder check (would mask rank/deps refusals), a feed-only reservation.](0400-a-chain-reserves-work-and-each-sub-quest-is-still-claimed-alone.md) | lifecycle / claims / game conversion |
520
520
  | 0402 | [**Helm is the hall's look, and the hall is dark only** ([task 1005192](https://cloudbongos.com/builders#/task/1005192)). The game (the hall, everything after sign-in) wears Helm; the landing, the hub and the status page keep the chrome world. The kit: two voices (Barlow Condensed in wide capitals names a thing, Barlow says everything else), four colours with one job each (Night the ground, Ice what you read, White names and numbers, the accent only what you can act on), three shapes (the cut corner for anything pressable, the segment for progress, the arc for the visor). The values live in the hall's token block; `src/hall-helm.js` recognises a hall page by the sheet it links, pins it dark and hands it the Helm faces unless its pack chose its own. The light/dark switch is gone; the light tier stays until its own removal. The shared button spends every Helm token with the old pill as the fallback, so the status page does not move.](0402-helm-is-the-halls-look-and-the-hall-is-dark-only.md) | design system / hall |
521
521
  | 0403 | [**In game mode the visor is the control and the scene is the view** ([task 1005203](https://cloudbongos.com/builders#/task/1005203), goal 1000142; owner 2026-11-11). Every main call to action you can see in game mode lives on the visor (the accent button, the key hints, the Actions sheet); the city, the sky and Home only show the project and can be moved through, never carry buttons of their own. A scene offers its actions to the visor through `OTBVisor.scene`. Found not yet keeping it: the city's decide button and door, the sky's top row and composer, Home's gallery links, the Artist's feedback tab.](0403-in-game-mode-the-visor-is-the-control-and-the-scene-is-the-view.md) | hall / game frame |
522
+ | 0404 | [**A party is a room, and rooms register as kinds** ([task 1005220](https://cloudbongos.com/builders#/task/1005220), goal 1000148). Any group that talks together is a room: a free-form party (cap 8), a chain party (ADR 0400), a scoping session (cap 40, owned by `lifecycle`) or a decision room (cap 40, `government`). Each owner contributes its kind to the `room.kinds` contribution point (a port is single-provider, so four owners would fail the boot) with `members`/`isMember`/optional `invite`/`accept`, and fires `room.opened`/`room.member.joined`/`room.member.left`/`room.closed`; consumers never read an owner's tables and reconcile against `members()`. Free-form party: one per builder, leader handover to the longest-standing member, disband on last leave. A chain room follows an open `chain_party` row. Unlinked players get everything but voice. V3 polls (3 s, cursor + 304, paused when hidden, per-builder limiter) rather than pushing through `/live`. With discord off, no voice key or voice state; chat still works. A room is never authority. Rejected: one port, a port per kind, a rooms module reading other modules' tables, a second party roster, push via `/live`, speaking indicators.](0404-a-party-is-a-room-and-rooms-register-as-kinds.md) | game frame / modules / Discord |
@@ -3434,5 +3434,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
3434
3434
  landed since 1.24.90 with no explicit bump. run 43296447627. (task 1002620)
3435
3435
  1.24.93 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3436
3436
  landed since 1.24.92 with no explicit bump. run 43299203682. (task 1002620)
3437
+ 1.24.94 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3438
+ landed since 1.24.93 with no explicit bump. run 43302410261. (task 1002620)
3437
3439
  ---------------------------------------------------------------------------
3438
3440
  ```
@@ -267,6 +267,8 @@ A module calls `api.seams.emit(eventName, payload)` to broadcast. Any module tha
267
267
  | Port: `builder-settings` | `builder-settings` (R80) | core `routes/me.js` (`GET /me`) + `cascade-dispatch.js` | `{ getBuilderBehaviorPrefs, describeWandering, describeRender, computeNeeds, skillCeiling }` — the per-builder prefs read surface. The `GET /me` aggregator resolves it for its wandering/render/needs slices; the dormant cascade router for the skill ceiling. Default-on; degrades gracefully when off ([ADR 0093](adr/0093-tranche-2-core-carve-sequence.md) §1, [02-module-map §2](design/modular-architecture/02-module-map.md)). |
268
268
  | Port: `onboarding` | `onboarding` (R81) | the lifecycle (`db.js` claim/ship — transaction-participant `markStage`) + `auth.js` + `routes/me.js`/`builders.js`/`tasks.js`/`claims.js` | `{ getState, shapeForApi, safeMarkStage, markStage, broadcastGraduation, restockForClaim, restockAll, SOURCE_TAG }` — the onboarding state-machine + newcomer-restock surface. The FIRST core→module port the lifecycle ticks inside its own txn (alongside `reward`). Default-on; degrades to best-effort no-ops when off ([ADR 0093](adr/0093-tranche-2-core-carve-sequence.md) §1). |
269
269
  | Port: `request.divert` | `npm-release` (its version preview; task 1004300, [ADR 0349](adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md)) | core `src/platform-server.js`, just before `mountInternalSurfaces(app)` | `(req, res, next) → void|Promise` — an **optional** provider, looked up on every request. It either answers the request or calls `next()`. With no provider registered the kernel calls `next()` at once, so an instance without the module is served exactly as before; a provider that throws is treated as absent for that request. Consumed with `resolveOptional`, never `resolve`. |
270
+ | Contribution point: `room.kinds` | each module that owns a group that talks: `lifecycle` (`party`, `chain`, `scoping`), `government` (`decision`) | the Discord room channels, room chat and party bars (goal 1000148) | [ADR 0404](adr/0404-a-party-is-a-room-and-rooms-register-as-kinds.md) — a **contribution point, not a port**, because several modules supply it. Each contribution is `{ kind, owner, cap, members(roomId) → [{ builderId, joinedAt }], isMember(roomId, builderId) → bool, invite?(), accept?() }`. Read with `contributions('room.kinds')`; a consumer never reads an owner's tables. Two contributions with the same `kind` are a configuration error. |
271
+ | Events: `room.opened` · `room.member.joined` · `room.member.left` · `room.closed` | the owner of the room's kind | the Discord room channels + access sync, room chat | [ADR 0404](adr/0404-a-party-is-a-room-and-rooms-register-as-kinds.md) — payloads carry ids only: `{ kind, roomId }`, plus `builderId` on joined/left and `reason` (`left` · `kicked` · `lapsed` · `deactivated`) on left. Listeners reconcile against `members()`, so a missed event is repaired, not lost. |
270
272
  | Event: `builder.secrets-scrub-needed` | core `db.js` (`deactivateBuilder`) | `art-pipeline` (when installed) | `{ builderId, actor }` — task 1003338 / [ADR 0332](adr/0332-offboarding-secret-scrub-semantics.md): best-effort teardown port for a MODULE-owned builder-keyed secret (today: the Gemini BYOK key, `gemini_key_enc`/`last4`/`set_at`). Core does not write those columns directly (ADR 0102) — the module listens and clears its own store. `github_oauth_tokens` (core-owned) is deleted synchronously inside `deactivateBuilder`'s transaction instead, no event needed. |
271
273
 
272
274
  ---
@@ -954,8 +954,8 @@
954
954
  "surface": "builders",
955
955
  "title": "Builders' Hall",
956
956
  "states_read": ["home","out","founding","genesis","decide","demo"],
957
- "files_hash": "6fce98a5eabe1c82",
958
- "reading_hash": "42901560779fc4d1",
957
+ "files_hash": "78dcb0fa10127d8c",
958
+ "reading_hash": "0ede747bdc35bbb5",
959
959
  "counts": {"lines":32,"placed":12,"shared":8,"unplaced":12},
960
960
  "omitted": {"data":5},
961
961
  "notes": ["founding: an action failed (page.focus: Timeout 6000ms exceeded.); read as it stood"],
@@ -977,7 +977,7 @@
977
977
  {"key":"L0015","section":"Needs your attention13","text":"Ideas awaiting triage","placement":"placed","file":"modules/hall-ui/public/builders.js","line":320,"string_id":"e181637c5a5e"},
978
978
  {"key":"L0016","section":"Needs your attention13","text":"Requires project {…} fix","placement":"unplaced"},
979
979
  {"key":"L0017","section":"footer","text":"Sign out","placement":"shared","file":"modules/hall-ui/public/shell.js","line":514,"string_id":"1883ad7f07fc"},
980
- {"key":"L0018","section":"Needs your attention13","text":"core 1.24.90","placement":"unplaced"},
980
+ {"key":"L0018","section":"Needs your attention13","text":"core 1.24.92","placement":"unplaced"},
981
981
  {"key":"L0019","section":"Needs your attention13","text":"Skip","placement":"unplaced"},
982
982
  {"key":"L0020","section":"Needs your attention13","text":"Built on","placement":"placed","file":"modules/hall-ui/public/welcome-boot.js","line":183,"string_id":"fef59346ffee"},
983
983
  {"key":"L0021","section":"Needs your attention13","text":"cloudbongos.com","placement":"unplaced"},
@@ -2931,7 +2931,7 @@
2931
2931
  "surface": "builders",
2932
2932
  "title": "Studio",
2933
2933
  "states_read": ["home","out","flow","flow-pick","status","words-offer","words-edit","reviews","full","full-back","approvals","approvals-before","approvals-phone","approvals-note","approved","sent-back","approvals-page"],
2934
- "files_hash": "8afa7e332b24559f",
2934
+ "files_hash": "97c8fcdb1e58d08c",
2935
2935
  "reading_hash": "c4e3b4188c1db755",
2936
2936
  "counts": {"lines":51,"placed":29,"shared":7,"unplaced":15},
2937
2937
  "omitted": {"data":23},
@@ -347,17 +347,28 @@
347
347
  wireTip();
348
348
  wireDoors();
349
349
  if (window.OTBHomeGallery) gallery = window.OTBHomeGallery.attach(host);
350
+ if (gallery && gallery.onChange) gallery.onChange(registerScene);
350
351
  if (window.OTBHomeIdeaBox) ideaBox = window.OTBHomeIdeaBox.attach(host);
351
352
  document.addEventListener('otb:craft', craftHome);
352
353
  craftHome();
353
354
  new MutationObserver(() => { if (model) drawScene(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
354
355
  document.addEventListener('otb:craft', () => { if (model) { drawScene(); drawLadder(); } });
355
356
  window.addEventListener('resize', () => { if (model) drawLadder(); });
356
- // task 1005148: Home's city as the visor's scene; R13 adds its camera's actions here.
357
- window.__visorScene = { name: 'home', place: 'Home' };
358
- if (window.OTBVisor) window.OTBVisor.scene(window.__visorScene);
357
+ registerScene();
359
358
  load();
360
359
  }
360
+ // task 1005148: Home's city as the visor's scene; R13 adds its camera's actions here.
361
+ // task 1005211 (ADR 0403): in Artistry Home is the gallery, and its controls are the visor's:
362
+ // the gallery's own rows (home-gallery.js visorActions), registered again when they change.
363
+ let sceneSig = null;
364
+ function registerScene() {
365
+ const actions = gallery && gallery.visorActions ? gallery.visorActions() : [];
366
+ const sig = actions.map((a) => a.label + '|' + (a.key || '')).join('\n');
367
+ if (sig === sceneSig && window.__visorScene) return;
368
+ sceneSig = sig;
369
+ window.__visorScene = { name: 'home', place: 'Home', actions };
370
+ if (window.OTBVisor) window.OTBVisor.scene(window.__visorScene);
371
+ }
361
372
 
362
373
  window.OTB.contributeHallWidget({
363
374
  id: 'home-city', label: 'The project', order: 15, core: true, mount,
@@ -127,3 +127,9 @@
127
127
  .hg-count { left: 50%; }
128
128
  .hg-salon { left: 20px; right: 20px; top: 196px; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 22px 16px; }
129
129
  }
130
+
131
+ /* task 1005211 (ADR 0403): in game mode the visor is the control and the gallery is the view.
132
+ Its order tabs, its ways (the full gallery, your studio), the arrows and the full-screen
133
+ button are rows on the visor (home-gallery.js visorActions); looking along the wall, a work's
134
+ label, its heart and a piece in the full gallery stay in the scene. Classic mode is as it was. */
135
+ html.visor-on .hg-sort, html.visor-on .hg-ways, html.visor-on .hg-edge, html.visor-on .hg-fsbtn { display: none; }
@@ -122,6 +122,29 @@
122
122
  var room = root.querySelector('.hg-room'), track = root.querySelector('.hg-track'), salon = root.querySelector('.hg-salon');
123
123
  var prev = root.querySelector('.hg-prev'), next = root.querySelector('.hg-next'), count = root.querySelector('.hg-count');
124
124
  var wayAll = root.querySelector('.hg-way-all'), empty = root.querySelector('.hg-empty');
125
+ var fsbtn = root.querySelector('.hg-fsbtn'), studio = root.querySelector('a.hg-way');
126
+ // task 1005211 (ADR 0403): in game mode the gallery's tabs, ways, arrows and full-screen
127
+ // button are the visor's (home-gallery.css hides them under html.visor-on). These are
128
+ // their rows, each pressing the gallery's own control, in the control's own words; Home
129
+ // (home-city.js) registers them as the scene's actions and asks again when they change.
130
+ var changed = function () {};
131
+ function visorActions() {
132
+ if (root.hidden) return [];
133
+ var all = room.classList.contains('is-all');
134
+ var out = [{ label: wayAll.firstChild.textContent.trim(), key: 'g', bind: true, lead: true, run: function () { wayAll.click(); room.focus(); } }];
135
+ root.querySelectorAll('.hg-sort button').forEach(function (b) {
136
+ out.push({ label: b.textContent + (b.getAttribute('aria-pressed') === 'true' ? ' · on' : ' · off'), run: function () { b.click(); } });
137
+ });
138
+ // the wall's own keys answer once the room has the keyboard, so a row hands it over
139
+ if (!all && list.length) {
140
+ out.push({ label: prev.getAttribute('aria-label'), key: '←', run: function () { prev.click(); room.focus(); } });
141
+ out.push({ label: next.getAttribute('aria-label'), key: '→', run: function () { next.click(); room.focus(); } });
142
+ out.push({ label: 'Full screen', key: 'F', run: function () { fsbtn.click(); room.focus(); } });
143
+ }
144
+ var href = studio.getAttribute('href');
145
+ out.push({ label: studio.firstChild.textContent.trim(), run: function () { window.location.assign(href); } });
146
+ return out;
147
+ }
125
148
  room.setAttribute('aria-label', ROOM_LABEL);
126
149
 
127
150
  function hang(w, i) {
@@ -239,6 +262,7 @@
239
262
  wayAll.firstChild.textContent = on ? 'Back to the bench ' : 'The full gallery ';
240
263
  room.setAttribute('aria-label', on ? ALL_LABEL : ROOM_LABEL);
241
264
  if (on) renderSalon();
265
+ changed();
242
266
  }
243
267
 
244
268
  // ---- full screen: the art alone, with its signature and heart ----
@@ -264,6 +288,7 @@
264
288
  if (order === b.dataset.order) return;
265
289
  order = b.dataset.order;
266
290
  root.querySelectorAll('.hg-sort button').forEach(function (x) { x.setAttribute('aria-pressed', String(x === b)); });
291
+ changed();
267
292
  load();
268
293
  });
269
294
  });
@@ -327,6 +352,7 @@
327
352
  if (mine !== asking) return;
328
353
  list = res && res.ok && res.data && Array.isArray(res.data.works) ? res.data.works.filter(function (w) { return w && w.id && w.artist; }) : [];
329
354
  render();
355
+ changed();
330
356
  if (first) { first = false; arrive(); }
331
357
  }, function () { if (mine === asking) { list = []; render(); } });
332
358
  }
@@ -348,7 +374,11 @@
348
374
  root.hidden = !on;
349
375
  if (on && !loaded) load();
350
376
  if (!on && isFs()) setFs(false);
377
+ changed();
351
378
  },
379
+ // task 1005211: the rows the visor shows for the gallery, and a hook for when they change
380
+ visorActions: visorActions,
381
+ onChange: function (fn) { changed = typeof fn === 'function' ? fn : function () {}; },
352
382
  };
353
383
  }
354
384
 
@@ -4,7 +4,7 @@ Covers: `visor.js` + `visor.css`, loaded on Home, `/city`, `/goal-map` and `/thi
4
4
 
5
5
  ## The rule every game page keeps (ADR 0403, owner 2026-11-11)
6
6
 
7
- The visor is the control and the scene is the view: every main call to action in game mode is on the glass (the accent button, the key hints, the Actions sheet), and a scene carries none of its own. A scene that has something to do registers it with `OTBVisor.scene({ actions, keys })`. ADR 0403 §3 lists the scenes that do not keep it yet; the Governor City keeps it since task 1005209 and the sky since task 1005210 (below).
7
+ The visor is the control and the scene is the view: every main call to action in game mode is on the glass (the accent button, the key hints, the Actions sheet), and a scene carries none of its own. A scene that has something to do registers it with `OTBVisor.scene({ actions, keys })`. ADR 0403 §3 lists the scenes that do not keep it yet; the Governor City keeps it since task 1005209 the sky since task 1005210, and Home's gallery since task 1005211 (below).
8
8
 
9
9
  ## A place's own buttons on the glass (task 1005209, the Governor City first)
10
10
 
@@ -20,6 +20,12 @@ The visor is the control and the scene is the view: every main call to action in
20
20
  - **A visor fix that came with it.** The sheet had a click listener of its own inside `#visor`'s, so every row ran twice; a toggle row undid itself. The sheet's listener is gone.
21
21
  - **Not done:** the tour still spots the pills it explains, which are off the glass in game mode. Tests: `tests/hall_sky_visor.mjs` (both pages' own markup, the real visor and sheets, in a real browser).
22
22
 
23
+ ## Home's gallery on the glass (task 1005211)
24
+
25
+ - **What left the gallery in game mode** (`home-gallery.css`, under `html.visor-on`): the order tabs (Most popular, Latest), the ways (The full gallery, Your studio), the two arrows and the full-screen button. A work's label, its heart and a piece in the full gallery stay: they are looking at the art, not leaving it.
26
+ - **Where they went.** `home-gallery.js` gives Home its rows (`visorActions()`, and `onChange()` for when they change), each pressing the gallery's own control in its own words: the full gallery (or "Back to the bench") is the lead on G; the two tabs with "· on/off"; Previous work ←, Next work →, Full screen F, which hand the wall the keyboard so its own keys answer next; Your studio. `home-city.js` registers them as Home's scene, and none in a craft whose Home is not the gallery.
27
+ - **Not done:** the attention cards and the Artist's idea box on Home are not the gallery's and still sit on the scene in game mode.
28
+
23
29
  ## The minimal visor (Helm look 3, task 1005195)
24
30
 
25
31
  The glass was redrawn to the owner's mock-up "Cloud city: in the visor, minimal". Look and layout only: every action, key, id, aria attribute, copy string and data source in `visor.js` is as task 1005148 left it. Where this section and the older bullets below disagree about the look or where a part sits, this section is current.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.93",
3
+ "version": "1.24.94",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.93",
9
+ "version": "1.24.94",
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.24.93",
3
+ "version": "1.24.94",
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",
@@ -11663,5 +11663,19 @@
11663
11663
  "id": "1005210",
11664
11664
  "text": "In game mode the sky (Goals and Thinking) no longer has buttons of its own: \"Write a thought\" is on the visor (press /), jumping is J, and the lens switch, the pills, the clock and the legend are rows in the visor's Actions li"
11665
11665
  }
11666
+ ],
11667
+ "1.24.94": [
11668
+ {
11669
+ "id": "1005247",
11670
+ "text": "The V3 game plan now records the owner's eight answers from the task cleanup, so new work is written against them."
11671
+ },
11672
+ {
11673
+ "id": "1005211",
11674
+ "text": "In game mode the Artist's Home gallery no longer has tabs, links or arrows of its own: the full gallery is on the visor (press G), and the order tabs, previous/next work, full screen and Your studio are rows in the visor's Act"
11675
+ },
11676
+ {
11677
+ "id": "1005220",
11678
+ "text": "The game's parties now have one agreed design: any group that plays together gets the same voice channel, member list and chat, whether it's a party you form, a quest party or a meeting room."
11679
+ }
11666
11680
  ]
11667
11681
  }
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.93'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.94'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -190,6 +190,9 @@ function listEvents() {
190
190
  // the slices merge into the GET /api/bongos/me payload.
191
191
  // (The client settings-panel seam is its browser twin,
192
192
  // window.OTB.contributeSettingsPanel in dom-utils.js.)
193
+ // • 'room.kinds' — one plain object per kind of room (party, chain, scoping,
194
+ // decision), contributed by the module that owns the group;
195
+ // read with contributions('room.kinds'). ADR 0404.
193
196
 
194
197
  const contributors = new Map(); // point -> [contribution, ...] (registration order)
195
198
 
@@ -218,3 +218,46 @@ test('Home is the gallery only in Artist: the city\'s widget switches on the cra
218
218
  assert.equal(art.hidden, false);
219
219
  assert.notEqual(back, 'none', 'and comes back in another craft');
220
220
  });
221
+
222
+ // ---- task 1005211 (ADR 0403): in game mode the gallery's controls are the visor's ----
223
+ test("the gallery's rows for the visor: each in its control's own words, each pressing that control", { skip }, async () => {
224
+ const { ctx, page } = await boot();
225
+ const rows = () => page.evaluate(() => window.__g.visorActions().map((a) => [a.label, a.key || '', !!a.lead, !!a.bind]));
226
+ assert.deepEqual(await rows(), [
227
+ ['The full gallery', 'g', true, true], ['Most popular · on', '', false, false], ['Latest · off', '', false, false],
228
+ ['Previous work', '←', false, false], ['Next work', '→', false, false], ['Full screen', 'F', false, false], ['Your studio', '', false, false],
229
+ ], 'the full gallery is the lead, on G; the wall keeps its own arrow keys and F');
230
+ await page.evaluate(() => { window.__changes = 0; window.__g.onChange(() => { window.__changes += 1; }); });
231
+ await page.evaluate(() => window.__g.visorActions().find((a) => a.label === 'Next work').run());
232
+ assert.equal((await state(page)).count, `2 of ${N}`);
233
+ assert.equal(await page.evaluate(() => document.activeElement.className), 'hg-room', 'a row hands the wall the keyboard, so the arrows answer next');
234
+ await page.keyboard.press('ArrowRight');
235
+ assert.equal((await state(page)).count, `3 of ${N}`);
236
+ await page.evaluate(() => window.__g.visorActions()[0].run());
237
+ assert.deepEqual((await rows()).map((r) => r[0]), ['Back to the bench', 'Most popular · on', 'Latest · off', 'Your studio'], 'in the full gallery there is no wall to walk');
238
+ assert.ok(await page.evaluate(() => window.__changes) >= 1, 'Home is told the rows changed');
239
+ await page.evaluate(() => window.__g.visorActions().find((a) => a.label === 'Latest · off').run());
240
+ await page.waitForFunction(() => window.__g.visorActions().some((a) => a.label === 'Latest · on'));
241
+ await ctx.close();
242
+ });
243
+
244
+ test('game mode: the tabs, the ways, the arrows and full screen leave the wall; classic mode keeps them', { skip }, async () => {
245
+ const { ctx, page } = await boot();
246
+ const shown = () => page.evaluate(() => ['.hg-sort', '.hg-ways', '.hg-prev', '.hg-next', '.hg-fsbtn'].map((s) => getComputedStyle(document.querySelector(s)).display !== 'none'));
247
+ assert.deepEqual(await shown(), [true, true, true, true, true], 'classic');
248
+ await page.evaluate(() => document.documentElement.classList.add('visor-on'));
249
+ assert.deepEqual(await shown(), [false, false, false, false, false], 'with the visor up');
250
+ assert.ok(await page.evaluate(() => getComputedStyle(document.querySelector('.hg-heart')).display !== 'none'), 'a heart is the work\'s, and stays');
251
+ await ctx.close();
252
+ });
253
+
254
+ test('Home registers the gallery\'s rows as its scene in Artist, and none in another craft', { skip }, async () => {
255
+ const { ctx, page } = await boot({ city: true });
256
+ await page.evaluate(() => { window.__scenes = []; window.OTBVisor = { scene: (s) => window.__scenes.push(s) }; window.__widget.mount(document.getElementById('home-city')); });
257
+ await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
258
+ assert.deepEqual(await page.evaluate(() => window.__visorScene.actions.length), 0, 'the builder\'s Home: no gallery rows');
259
+ await page.evaluate(() => { document.documentElement.dataset.craft = 'artist'; document.dispatchEvent(new CustomEvent('otb:craft', { detail: { mode: 'artist' } })); });
260
+ await page.waitForFunction(() => window.__visorScene.actions.length > 0 && window.__visorScene.actions[0].label === 'The full gallery');
261
+ assert.equal(await page.evaluate(() => window.__visorScene.place), 'Home');
262
+ await ctx.close();
263
+ });
@@ -0,0 +1,68 @@
1
+ // tests/room_kinds_seam.mjs — ADR 0404 (task 1005220): rooms register as kinds
2
+ // through the 'room.kinds' CONTRIBUTION POINT, because several modules own a kind
3
+ // (lifecycle: party, chain, scoping; government: decision) and a port is
4
+ // single-provider. Pins that choice: two kinds from two modules register side by
5
+ // side and the boot check still passes, while the same pair as a port is refused.
6
+ import assert from 'node:assert/strict';
7
+ import { test } from 'node:test';
8
+ import { createRequire } from 'node:module';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
14
+ const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
15
+
16
+ function kind(name, owner, members) {
17
+ return {
18
+ kind: name,
19
+ owner,
20
+ cap: name === 'party' ? 8 : 40,
21
+ members: async () => members,
22
+ isMember: async (_roomId, builderId) => members.some((m) => m.builderId === builderId),
23
+ };
24
+ }
25
+
26
+ test('two kinds from two modules both register, and the boot check passes', async () => {
27
+ seams._reset();
28
+ seams.contribute('room.kinds', kind('scoping', 'lifecycle', [{ builderId: 1, joinedAt: 't' }]));
29
+ seams.contribute('room.kinds', kind('decision', 'government', [{ builderId: 2, joinedAt: 't' }]));
30
+
31
+ const kinds = seams.contributions('room.kinds');
32
+ assert.deepEqual(kinds.map((k) => k.kind), ['scoping', 'decision']);
33
+ assert.deepEqual(kinds.map((k) => k.owner), ['lifecycle', 'government']);
34
+
35
+ // a consumer finds a kind by name and calls it — it never reads the owner's tables
36
+ const decision = kinds.find((k) => k.kind === 'decision');
37
+ assert.equal(await decision.isMember('r1', 2), true);
38
+ assert.equal(await decision.isMember('r1', 1), false);
39
+
40
+ // the modules' manifests declare no port for rooms, so the boot check is clean
41
+ const manifests = [
42
+ { key: 'lifecycle', provides: [], consumes: [] },
43
+ { key: 'government', provides: [], consumes: [] },
44
+ ];
45
+ assert.deepEqual(seams.verifyPortsSatisfied(manifests), { ok: true, errors: [] });
46
+ seams._reset();
47
+ });
48
+
49
+ test('the rejected design: the same pair as one port is refused at boot and at registration', () => {
50
+ seams._reset();
51
+ const manifests = [
52
+ { key: 'lifecycle', provides: ['room.members'], consumes: [] },
53
+ { key: 'government', provides: ['room.members'], consumes: [] },
54
+ ];
55
+ const res = seams.verifyPortsSatisfied(manifests);
56
+ assert.equal(res.ok, false);
57
+ assert.match(res.errors.join('\n'), /room\.members.*more than one enabled module/);
58
+
59
+ seams.registerProvider('room.members', async () => []);
60
+ assert.throws(() => seams.registerProvider('room.members', async () => []));
61
+ seams._reset();
62
+ });
63
+
64
+ test('an instance with no room owners has no rooms, and nothing throws', () => {
65
+ seams._reset();
66
+ assert.deepEqual(seams.contributions('room.kinds'), []);
67
+ seams._reset();
68
+ });