@bongos/core 1.19.1014 → 1.19.1016
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 +145 -140
- package/.claude/skills/planning-session/SKILL.md +1 -1
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md +4 -1
- package/docs/adr/0331-the-sky-is-the-ideators-home.md +18 -1
- package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +113 -0
- package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +201 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +4 -0
- package/docs/api-reference.md +1 -1
- package/docs/architecture.md +4 -1
- package/docs/copy-inventory.md +139 -110
- package/docs/copy-registry.json +872 -296
- package/docs/file-map.md +0 -1
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_253_idea_filed_via.sql +59 -0
- package/modules/agents/pollers/event-router.js +2 -0
- package/modules/discord/discord-bugs.js +2 -0
- package/modules/discord/discord-inbound.js +3 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +125 -52
- package/modules/hall-ui/public/goal-map.states.json +29 -5
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/people.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +11 -0
- package/modules/hall-ui/public/sky-data.js +41 -9
- package/modules/hall-ui/public/sky-draw.js +3 -2
- package/modules/hall-ui/public/sky-interaction.js +14 -7
- package/modules/hall-ui/public/sky-layout.js +2 -1
- package/modules/hall-ui/public/sky-panel.js +1 -1
- package/modules/hall-ui/public/sky-tour.js +10 -6
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.css +6 -3
- package/modules/hall-ui/public/thinking.html +16 -10
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/ideas/born-of.js +55 -0
- package/modules/ideas/inbox.js +23 -8
- package/modules/ideas/projection.js +48 -0
- package/modules/ideas/routes/inbox.js +20 -0
- package/modules/lifecycle/sky.js +25 -4
- package/modules/platform-identity/activity-snapshots.js +47 -0
- package/modules/platform-identity/migrations/platform_identity_022_activity_snapshots.sql +46 -0
- package/modules/platform-identity/platform-identity.js +13 -0
- package/modules/platform-identity/tests/activity-snapshots.mjs +68 -0
- package/modules/platform-identity/tests/platform-identity.mjs +21 -0
- package/modules/ui-design/skills/impeccable/SKILL.md +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/capture.js +11 -0
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/bongos/serve-internal.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/activity_snapshots_db.mjs +97 -0
- package/tests/discord_bugs.mjs +1 -0
- package/tests/discord_inbound.mjs +1 -0
- package/tests/goal_map_page.mjs +360 -211
- package/tests/guild_namespace_reserved.mjs +76 -0
- package/tests/hall_audit.mjs +1 -0
- package/tests/hall_nav.mjs +6 -1
- package/tests/hall_page_gate_map.mjs +3 -2
- package/tests/hall_sky.mjs +33 -16
- package/tests/helpers.mjs +21 -5
- package/tests/idea_filed_via.mjs +185 -0
- package/tests/landing_page.mjs +8 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/profile_rollup_consent.mjs +5 -1
- package/tests/project_admin_console_model.mjs +105 -0
- package/tests/render_check.mjs +6 -4
- package/tests/sky_read.mjs +19 -2
- package/tests/ui_design_skills.mjs +9 -0
- package/modules/hall-ui/public/goal-map-lib.js +0 -349
- package/modules/hall-ui/public/goal-map.css +0 -117
- package/modules/hall-ui/public/goal-map.js +0 -232
- package/modules/public-landing/public/world/assets/clouds/cloud-island-large.png +0 -0
- package/modules/public-landing/public/world/assets/clouds/cloud-puff.png +0 -0
- package/modules/public-landing/public/world/assets/creatures/creature-whale-cargo.png +0 -0
- package/modules/public-landing/public/world/assets/creatures/creature-whale-glide.png +0 -0
- package/modules/public-landing/public/world/assets/plates/plate-clouds-near.png +0 -0
- package/modules/public-landing/public/world/assets/plates/scene-establishing.png +0 -0
- package/tests/goal_map_lib.mjs +0 -214
|
@@ -35,7 +35,7 @@ This mirrors GDS-V3 criterion C2 (`three-ranks-gate-everything`) and matches the
|
|
|
35
35
|
|
|
36
36
|
1. Call `GET /api/bongos/me` and read `builder.rank`. Compare case-insensitively (lowercase it before testing).
|
|
37
37
|
2. **If `rank` is present and lowercases to `xenos`** → stop immediately. Tell the builder: *"Planning sessions set the scope-truth of a version — they're a Metic+ operation. You're currently Xenos. You can still **file** a criterion proposal or task idea via `POST /api/bongos/inbox` (which lands in idea_inbox for a Metic+ builder to ratify at the next planning session); ask an Archon for promotion if you need to run sessions directly."* Do not pull state, do not present criteria, do not propose tasks.
|
|
38
|
-
3. **If `rank` lowercases to `metic`** → proceed in **Metic-mode**: run the five-phase structure below conversationally, but in Phase 5 write proposals to `idea_inbox` (kind=`criterion-proposal` for criteria, kind=`feature` for tasks) tagged with the target version. Any Metic+ builder ratifies them (`idea.ratify`, metic-floored since ADR 0157) — typically at the next planning session. Since [ADR 0090](../../../docs/adr/0090-metic-task-authoring.md) a Metic MAY `POST /tasks` directly, and since [ADR 0154](../../../docs/adr/0154-goal-owner-criterion-authoring.md) a Metic MAY write a `done_when_criteria` row for a goal they own or manage. What needs an Archon is a VERSION-level criterion (no `goal_id`), one in a goal you don't hold, and — since [ADR 0250](../../../docs/adr/0250-strict-versioning-the-version-boundary-is-the-scope-gate.md) §6 (task 1003608) — **creating a version at all**: `version.create` returned to the Archon floor, because once the version boundary carries the scope gate, cutting a version is the way around it rather than authoring. ADR 0157 had briefly delegated it to Metic; it no longer is. The spec-draft (Phase 3) and the in-depth interview (Phase 4) run identically in Metic-mode — the only difference is the Phase-5 write (you file proposals, you don't seed tasks directly).
|
|
38
|
+
3. **If `rank` lowercases to `metic`** → proceed in **Metic-mode**: run the five-phase structure below conversationally, but in Phase 5 write proposals to `idea_inbox` (kind=`criterion-proposal` for criteria, kind=`feature` for tasks) tagged with the target version — filed with `node scripts/gds/capture.js "…" --via planning` (or `filed_via: 'planning'` on `POST /inbox`), so the sky records the door they came through (task 1004233; every other filing's door is read off the session). Any Metic+ builder ratifies them (`idea.ratify`, metic-floored since ADR 0157) — typically at the next planning session. Since [ADR 0090](../../../docs/adr/0090-metic-task-authoring.md) a Metic MAY `POST /tasks` directly, and since [ADR 0154](../../../docs/adr/0154-goal-owner-criterion-authoring.md) a Metic MAY write a `done_when_criteria` row for a goal they own or manage. What needs an Archon is a VERSION-level criterion (no `goal_id`), one in a goal you don't hold, and — since [ADR 0250](../../../docs/adr/0250-strict-versioning-the-version-boundary-is-the-scope-gate.md) §6 (task 1003608) — **creating a version at all**: `version.create` returned to the Archon floor, because once the version boundary carries the scope gate, cutting a version is the way around it rather than authoring. ADR 0157 had briefly delegated it to Metic; it no longer is. The spec-draft (Phase 3) and the in-depth interview (Phase 4) run identically in Metic-mode — the only difference is the Phase-5 write (you file proposals, you don't seed tasks directly).
|
|
39
39
|
|
|
40
40
|
**For each criterion proposal**, write `body_md` as a JSON object with this exact shape so the `POST /api/bongos/inbox/:id/ratify` endpoint (V3.R31 / #252) can parse it cleanly:
|
|
41
41
|
|
|
@@ -336,7 +336,7 @@ export interface PostInboxIdRatifyRequest { notes_md?: string }
|
|
|
336
336
|
export interface PostInboxIdResubmitRequest { field_values: Record<string, unknown>; template_key?: string; recommendation?: string }
|
|
337
337
|
export interface PostInboxIdSparkResponse { idea: unknown; sparked: boolean; unchanged: boolean }
|
|
338
338
|
export interface PostInboxPreviewLandingRequest { goal_id?: number; kind?: string; grade?: string }
|
|
339
|
-
export interface PostInboxRequest { title: string; body_md?: string; kind?: string; suggested_version?: string; suggested_priority?: number; goal_id?: number; grade?: string; template_key?: string; field_values?: Record<string, unknown>; recommendation?: string }
|
|
339
|
+
export interface PostInboxRequest { title: string; body_md?: string; kind?: string; suggested_version?: string; suggested_priority?: number; goal_id?: number; grade?: string; template_key?: string; field_values?: Record<string, unknown>; recommendation?: string; filed_via?: string }
|
|
340
340
|
export interface PostInboxScoreRequest { field_values: Record<string, unknown>; template_key?: string }
|
|
341
341
|
export interface PostInboxScoreResponse { score: unknown; fields: unknown; template_key: unknown }
|
|
342
342
|
export interface PostLearningsRequest { title: string; body_md: string; tags?: string[]; related_files?: string[]; source?: string; source_ref?: string; task_id?: number }
|
|
@@ -304,7 +304,10 @@ drawn as one page instead of two:
|
|
|
304
304
|
- **The nav slot the goal map was denied is granted to the sky**, under Goals (owner,
|
|
305
305
|
2026-09-24). The goal map page (task 1004105) took no slot on purpose, and a test asserts the
|
|
306
306
|
same; part 4 ([#1004232](https://cloudbongos.com/builders#/task/1004232)) changes the page and
|
|
307
|
-
that test together, with this section as the licence.
|
|
307
|
+
that test together, with this section as the licence. **Landed 2026-09-24:** `/goal-map` serves
|
|
308
|
+
the sky in its whole-project lens, the goal map's files and the test that pinned its missing
|
|
309
|
+
slot are gone, and "Sky" sits under Goals in the nav (`tests/goal_map_page.mjs` now pins the
|
|
310
|
+
slot, the ledger drawer and this section's task rule).
|
|
308
311
|
|
|
309
312
|
**Design of record:** [`docs/design/mocks/sky/`](../design/mocks/sky/) — the pixel contract for
|
|
310
313
|
the build. [ADR 0264](0264-the-ten-working-areas.md)'s deletion of the goal *Living project sky*
|
|
@@ -71,6 +71,15 @@ Parts 3 and 4 **hide** both layers. Part 5 replaces them with ledger facts: a `f
|
|
|
71
71
|
and a real born-of-a-thought read. A heuristic drawn in the sky would be indistinguishable from
|
|
72
72
|
the facts beside it, which is the exact failure the criterion exists to prevent.
|
|
73
73
|
|
|
74
|
+
**Part 5 landed on 2026-09-24 (task 1004233) and both are ledger facts now.** `idea_inbox.filed_via`
|
|
75
|
+
(`hall | cli | discord | planning | api`; core migration `core_253_idea_filed_via`) is read off the session that
|
|
76
|
+
filed — a web cookie is the hall, a device-flow token is the CLI, anything else the API; the body
|
|
77
|
+
may add only `'planning'`, the Discord bot names `'discord'` on the port — and rows older than the
|
|
78
|
+
column stay NULL, shown by default and spoken as "filed before the ledger recorded where", never
|
|
79
|
+
backfilled from text. `GET /sky` returns `born: { goals, criteria }` from the two ratify tables
|
|
80
|
+
through the `ideas.bornOf` port, and both pages draw the nucleus ring, the notch and the thought's
|
|
81
|
+
place from it. The "claude layer" hides `cli`, `api` and `discord`.
|
|
82
|
+
|
|
74
83
|
## 5. Consequences
|
|
75
84
|
|
|
76
85
|
- The build (parts 2–5) is graded against `docs/design/mocks/sky/sky.template.html` as a **pixel
|
|
@@ -80,7 +89,15 @@ the facts beside it, which is the exact failure the criterion exists to prevent.
|
|
|
80
89
|
one line each in
|
|
81
90
|
[`docs/session-logs/<redacted>.md`](../session-logs/<redacted>.md)
|
|
82
91
|
— the two §4 layers hidden, the bands derived from `GET /versions` rather than named, and
|
|
83
|
-
`/me` resolving `idea.triage` for the nod.
|
|
92
|
+
`/me` resolving `idea.triage` for the nod. **Part 4 landed on 2026-09-24 (task 1004232):**
|
|
93
|
+
the project sky at `/goal-map` — the SAME page opened in its "the whole project" lens
|
|
94
|
+
(the body's `data-sky-lens` names the home lens; the switcher leaves for the other page),
|
|
95
|
+
replacing the goal map (task 1003479) and taking the nav slot under Goals that ADR 0227 §11
|
|
96
|
+
granted; its forced deviations are listed in
|
|
97
|
+
[`docs/session-logs/<redacted>.md`](../session-logs/<redacted>.md).
|
|
98
|
+
**Part 5 landed on 2026-09-24 (task 1004233):** the two §4 heuristics became facts —
|
|
99
|
+
`filed_via` and the `born` read — and the pages draw them; see §4 and
|
|
100
|
+
[`docs/session-logs/<redacted>.md`](../session-logs/<redacted>.md).
|
|
84
101
|
- The template ships with **no snapshot**: the snapshot script dumps real task, idea and goal
|
|
85
102
|
titles, and this repo is public. `docs/design/mocks/sky/.gitignore` refuses both the snapshot
|
|
86
103
|
and the built page.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# ADR 0335 — An invite is a pre-approved row on the project's own instance, and `project.recruit` says who may send one
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-09-24
|
|
5
|
+
- **Task:** [task 1002813](https://cloudbongos.com/builders#/task/1002813) (R1 of the project admin console wave, goal 1000110 — BONGOS-V2)
|
|
6
|
+
- **Deciders:** the three decisions come from the spike ([task 1002786](https://cloudbongos.com/builders#/task/1002786), [`docs/specs/<redacted>.md`](../specs/<redacted>.md), 2026-08-14). The spike asked the owner for one sign-off: what "widenable to metics" means. The owner answered on blocker 1000122, and [ADR 0217](0217-rank-gates-inviting-not-viewing.md) D1 records the answer: a per-project invite floor that defaults to archon and is set by that project's own archons. This ADR does three things. It records the decisions, reconciles them with what has shipped in the six weeks since, and says what each build task still has to build.
|
|
7
|
+
- **Supersedes:** only the "Invitations (deferred slice)" paragraph of [ADR 0141](<redacted>.md) §5. The rest of ADR 0141 stands.
|
|
8
|
+
- **Revises:** [ADR 0210](0210-scouting-graduates-on-an-instance-asserted-owner-claim.md), [ADR 0215](0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md) and [ADR 0248](0248-a-hub-project-s-owner-is-the-provisioning-row-carried-on-a-port.md) predicted that `project.recruit` would replace `project.curate` on the hub's recruiting doors. D3.6 revises that prediction.
|
|
9
|
+
- **Related:** [ADR 0016](0016-trust-boundary-server-enforced-permissions.md), [0108](0108-instance-composition-configurable-root.md), [0112](0112-private-goals-invitations-join-requests.md), [0151](0151-governance-permissions-as-atom-ranks-as-roles.md), [0157](0157-archon-is-rank-and-identity-only.md), [0182](0182-project-visibility-two-axes.md), [0194](0194-joinability-one-door-decision.md), [0201](0201-cross-project-application-vouched-row-live-record.md), [0247](0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md), [0328](0328-a-hosted-projects-authority-is-its-membership-row-not-its-owner-column.md)
|
|
10
|
+
|
|
11
|
+
## Context
|
|
12
|
+
|
|
13
|
+
The spike found two membership planes that do not talk to each other:
|
|
14
|
+
|
|
15
|
+
- The **goal plane** (ADR 0112) is per-instance and complete.
|
|
16
|
+
- The **project plane** was half-built. An application was an `access_requests` row on the target instance. The only outbound invite lived on the hub, and accepting it let nobody in.
|
|
17
|
+
|
|
18
|
+
Six weeks of shipping since then have changed what is left to build. Checked against `main` on 2026-09-24:
|
|
19
|
+
|
|
20
|
+
| What the spike found | What live code does now |
|
|
21
|
+
|---|---|
|
|
22
|
+
| An archon cannot invite someone who has not applied. | The instance has `POST /access-requests/invite` ([task 1003044](https://cloudbongos.com/builders#/task/1003044)), gated by `access_request.review` (archon). It writes a `status='invited'` row, so the invitee's first sign-in admits them. If that login already has a pending application, the route approves that row in place. |
|
|
23
|
+
| No rescind. | `PATCH /access-requests/:id` rescinds (`invited → dismissed`) as long as the login has no builder row yet (ADR 0201 D4.2). |
|
|
24
|
+
| Nothing records who invited or why, and a sender has no list of their invites. | **Still true.** An invite row looks exactly like an approved application. `resolved_by_builder_id` names the inviter only until a rescind overwrites it. The route takes no note. |
|
|
25
|
+
| No join policy anywhere. | The join door is now composed: `open \| apply \| invite_only \| locked`, from visibility, join grant and joinability (ADR 0194, ADR 0182 D6, ADR 0247; task 1002331, task 1003044). A closed door refuses the public application POST, and the sign-in gate reads the same door. The hub's join relay says *"This project is invite-only — ask its owner for an invite; once invited, sign in to it."* The owner sets this on the hub's manage page, and a restart applies it (no deploy). |
|
|
26
|
+
| The hub invite is `project.curate`-only, and nothing calls it. | `POST /projects/invite` now admits a `project.curate` holder **or** the project's own owner (`invite-authz.js`, task 1003580). The creation wizard's invite step calls it (task 1002346). It still pre-seeds a `pending` hub row. Accepting that row (`resolveInvite`) flips it to `member`, but no instance reads hub membership to decide who may enter. **So a real button now leads to the dead end the spike found.** |
|
|
27
|
+
| `project.recruit` is proposed. | **Still unminted.** Five places wait for it: ADR 0201 D3.2 (the queue's read half); ADR 0210 and ADR 0248 (the `project.curate` half of the hub's owner-or-rank doors); ADR 0215 D4 and ADR 0217 D2 (the hub recruiter sliver, held at `requireRank('archon')` as the documented holdout in `tests/government_require_permission.mjs`); and task 1003362 (the per-project invite floor). |
|
|
28
|
+
|
|
29
|
+
## Decision
|
|
30
|
+
|
|
31
|
+
### D1 — Authority lives on the project's own instance. The instance *is* the project.
|
|
32
|
+
|
|
33
|
+
Every project is its own instance with its own `builders.rank` (ADR 0108). That makes "per-project recruit rules" and "per-instance governance" the same thing. The problem of scoping a governance grant to one project goes away.
|
|
34
|
+
|
|
35
|
+
The hub keeps the role ADR 0141 gave it:
|
|
36
|
+
|
|
37
|
+
- identity (§1)
|
|
38
|
+
- the join relay into the project's own public POST (§3)
|
|
39
|
+
- the statusless application echo (ADR 0201 D1.3)
|
|
40
|
+
- notifications
|
|
41
|
+
- links
|
|
42
|
+
|
|
43
|
+
The hub **never writes a project's admission row and never holds a verdict.** `tests/project_admin_console_model.mjs` checks that no `platform-identity` source writes to `access_requests`.
|
|
44
|
+
|
|
45
|
+
This decides where things live. The console is a hall page on the project's instance (R4). The apex only links to it (R7).
|
|
46
|
+
|
|
47
|
+
ADR 0328 does not contradict D1. It answers a different question: who may act on a project's hosting row on the hub (`provisioning_instances`). A hub membership row has no say in who may enter the project.
|
|
48
|
+
|
|
49
|
+
### D2 — A recruit invite is a pre-approved `access_requests` row, and signing in is the acceptance act
|
|
50
|
+
|
|
51
|
+
There is no new table. The sign-in gate (`hasInvitedAccessRequest` → `firstTimeAdmissionBlocked`) already reads `access_requests`, so an invite is simply a row that gate reads, and the gate does not change. The row gains the ADR 0112 discriminators, added without changing anything that exists:
|
|
52
|
+
|
|
53
|
+
1. **`kind ∈ ('application','invite')`, NOT NULL, default `'application'`.** The public POST keeps writing applications through the default, and the invite route writes `'invite'`. If an invite approves a pending application in place, the row stays `'application'`: it was an application, and its history should say so.
|
|
54
|
+
2. **`created_by` → `builders(id)`, set on invite rows only.** This is the inviter. It needs its own column because a rescind overwrites `resolved_by_builder_id` (ADR 0201 D4.2 records whoever acted last). Without it, a rescinded invite forgets who sent it.
|
|
55
|
+
3. **`note` needs no new column.** The existing column holds the inviter's note on an invite row, and `kind` says whose words they are.
|
|
56
|
+
4. **The backfill is exact.** The current invite route sets `resolved_at = now()` in the same statement whose default fills `created_at`, so every row it wrote has `created_at = resolved_at`. No approved application can match that, because an application is always resolved after it was filed. So the migration sets `kind='invite'` and `created_by = resolved_by_builder_id` on `status='invited'` rows where `created_at = resolved_at`. The one case it cannot catch is a direct invite that was later rescinded, because the rescind changes `resolved_at`. That row stays `'application'`, which makes it look like a declined application. It admits nobody either way. The only effect comes after R3: a recruiter who holds `project.recruit` but not `access_request.review` and re-invites that login gets the D3.4 refusal, so a reviewer has to send the invite. That leaves the door stricter, never looser, and applies only to invites rescinded before R2 ships.
|
|
57
|
+
5. **`kind` and `created_by` never enter an admission read.** This is the same rule `core_230` set for the vouch column. An invite and an approved application admit through the identical `status = 'invited'` read. `tests/project_admin_console_model.mjs` checks this.
|
|
58
|
+
6. **Expiry is not part of this.** The 30-day read-time expiry (ADR 0201 D4.3) covers every invited row, whatever its `kind`. Task 1002969 owns it.
|
|
59
|
+
|
|
60
|
+
### D3 — One new grantable atom, `project.recruit` (floor archon), says which rank may recruit; the verdict stays with `access_request.review`
|
|
61
|
+
|
|
62
|
+
1. **Shape.** `system: false`, so it can go into a custom rank, which is how spec D3 handles "let this one person recruit". `floor: 'archon'`. It is not a substrate key and has no principal. A `government_NNN` grant migration seeds it to the archon rank, in the same shape as `government_016`; the seed drift guard requires that migration. `tests/project_admin_console_model.mjs` checks this shape as soon as the atom exists.
|
|
63
|
+
2. **Widening uses the governance tab that already exists.** On that project's instance, an archon calls `PUT /government/ranks/:rankKey/permissions/:permissionKey` (gated by `government.manage`, archon). The spec wrote this as `/governance/roles/…`; the path above is the live one. **This is the per-project invite floor the owner asked for** (ADR 0217 D1): it belongs to the project, defaults to archon, and only that project's archons can move it.
|
|
64
|
+
3. **What each atom guards on an instance.**
|
|
65
|
+
|
|
66
|
+
| Surface | Atom |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `POST /access-requests/invite`, the outstanding-invite list, and rescinding a `kind='invite'` row | `project.recruit` |
|
|
69
|
+
| `GET /access-requests` (the queue read, which ADR 0201 D3.2 calls the "read half") | `project.recruit` |
|
|
70
|
+
| `PATCH /access-requests/:id` (approve, decline, or rescind any row): the verdict | `access_request.review`, unchanged. ADR 0157 classes it as identity. |
|
|
71
|
+
| The Discord approval reaction (`modules/discord/discord-approvals.js`) | Unchanged. It is a pending-only verdict path at archon rank. |
|
|
72
|
+
|
|
73
|
+
An archon holds both atoms by default. A custom rank meant to review applications needs both: `access_request.review` on its own can rule on a request by its id but cannot list the queue.
|
|
74
|
+
4. **Inviting is admitting, so the line between the two atoms needs one clause to be real.** An invite writes the row the gate reads, so a recruiter can admit any login they choose. That is exactly the power the owner hands out by widening the floor. What a recruiter must not do is rule on someone who *asked*, and today's invite route does both kinds of ruling: it approves a pending application in place, and it re-admits a declined applicant (the PATCH refusal deliberately points callers at it). So the rule is: **when the caller holds `project.recruit` but not `access_request.review`**, the invite route refuses with a 409 that points to the queue if the login has a pending application, or if its latest application was dismissed. A caller who holds both keeps today's behaviour. Without this clause, "the verdict stays archon" would be a label on a door another route already opens. The 409 does tell a recruiter that the login applied, but a recruiter can already read the queue (row 3 above).
|
|
75
|
+
5. **The hub is an instance too, so its own copy of the atom governs its own recruiting.** The hub's recruiter sliver (`GET /scouting/:handle`) moves from `requireRank('archon')` to `requirePermission('project.recruit')`. At floor archon, nothing changes for users. It stops being the holdout listed in `RETAINED` in `tests/government_require_permission.mjs`, which is what that entry and ADR 0215 said the console wave would do.
|
|
76
|
+
6. **The hub's directory and the hub's invite keep `project.curate`.** ADR 0210, 0215 and 0248 expected `project.recruit` to replace the `project.curate` half of those owner-or-rank doors. For the directory (`GET /scouting`) that would be wrong. The directory shows public, consented records, and ADR 0217 D1 puts those on the *viewing* side of its line. Swapping a metic-floor atom for an archon-floor one would put a higher gate on a viewing surface, which is the one change ADR 0217's standing invariant forbids. The hub invite lets nobody in, so a stricter gate on it would protect nothing; R6 changes what it is.
|
|
77
|
+
7. **Task 1003362 asked which invite surface the owner's floor gates. The answer is the instance's `POST /access-requests/invite`, not the hub's `POST /projects/invite`**, because only the instance row lets anyone in.
|
|
78
|
+
|
|
79
|
+
## What each build task builds now
|
|
80
|
+
|
|
81
|
+
| Task | What it builds | Already true — do not rebuild |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| **R2** · [task 1002814](https://cloudbongos.com/builders#/task/1002814) | The D2 migration: `kind`, `created_by`, and the exact backfill. The invite route writes `kind='invite'`, `created_by` and an optional `note`. A list of outstanding invites (`kind='invite'`, `status='invited'`, and no builder row yet). **Everything stays gated by `access_request.review`**, because the new atom does not exist yet. Tests extend the access-requests suite. | Inviting a login that never applied, signing that login straight in, and an archon rescinding the invite before first sign-in (through `PATCH`). Its done-when narrows to three things: an invite row can be told apart from an approved application, it keeps its sender through a rescind, and the sender can list their outstanding invites. |
|
|
84
|
+
| **R3** · [task 1002815](https://cloudbongos.com/builders#/task/1002815) | Mint `project.recruit` in the D3.1 shape, with its grant migration. Move the D3.3 surfaces onto it, adding a rescind route for recruiters that acts only on `kind='invite'` rows (reviewers keep `PATCH`). Add the D3.4 clause. Swap the hub sliver and remove its `RETAINED` entry (D3.5). Pin the new gates in `route-rank-check.js`. Update the catalog `guards` text for both atoms. Add a widening proof: grant metic on a test instance and a metic can invite; revoke it and they get a 403. **R3 lands after R2** because the clause and the recruiter rescind both read `kind`, so it needs a dependency on task 1002814. | The governance grant/revoke routes and the tab. |
|
|
85
|
+
| **R5** · [task 1002817](https://cloudbongos.com/builders#/task/1002817) | **Nothing — it has already been delivered.** "Closed" is joinability `invite_only` or join grant `view`. The application POST refuses with copy that does not say which setting closed the door. The hub relay tells the applicant why. Changing the setting needs a restart, not a deploy. Close R5 as merged into task 1002331. | All of it: task 1003044, task 1002331, ADR 0247. |
|
|
86
|
+
| [task 1003362](https://cloudbongos.com/builders#/task/1003362) | **Folds into R3.** Its done-when maps one-to-one onto D3.2. The ADR it asks for (the split between inviting and viewing) is ADR 0217, and D3.7 answers its surface question. A separate per-project setting would be exactly the rival knob that task warns against. Merge it into task 1002815. | — |
|
|
87
|
+
|
|
88
|
+
The other three tasks keep their scope, with two notes:
|
|
89
|
+
|
|
90
|
+
- **R4** ([task 1002816](https://cloudbongos.com/builders#/task/1002816)) shows the join door **read-only**, with a link to wherever it is set. For a hosted project, that is the hub's manage page. R4 must not create a second place to store it.
|
|
91
|
+
- **R6** ([task 1002818](https://cloudbongos.com/builders#/task/1002818)) now owns a dead end that real users can reach: the wizard's invite step. Whatever R6 builds, the project's instance decides admission using its own `project.recruit` check. The hub may carry the inviter's *identity*, using the audience-scoped signed assertion from ADR 0141 §1, which already exists. It never carries the verdict.
|
|
92
|
+
- **R7** ([task 1002819](https://cloudbongos.com/builders#/task/1002819)) is unchanged.
|
|
93
|
+
|
|
94
|
+
## Alternatives considered
|
|
95
|
+
|
|
96
|
+
- **A new invitations table**, shaped like ADR 0112's `goal_membership_requests`. Rejected: the gate already reads `access_requests`, so a second table would mean a second admission read to keep in sync.
|
|
97
|
+
- **Keep the hub's pending membership as the invite**, as ADR 0141 §5 had it. Rejected: the hub holds no authority, so accepting that invite lets nobody in. That is the dead end this ADR retires.
|
|
98
|
+
- **No new atom; widen `access_request.review` instead.** This is tempting: that atom is already `system: false` and grantable per instance, and task 1003362 asks for no rival knob. Rejected for three reasons. Widening it would hand out the verdict along with the invite, when the owner asked for a floor on *inviting* only. ADR 0157 classes the verdict as identity. And five earlier decisions already name `project.recruit` as the recruiting atom.
|
|
99
|
+
- **A per-project settings row for the invite floor**, reading task 1003362 literally. Rejected: governance grants are already per instance, and D1 makes an instance a project. A settings row would be a second answer to the same question.
|
|
100
|
+
- **Let a recruiter's invite approve applications, as the route does today.** Rejected: the verdict floor would become a label (see D3.4).
|
|
101
|
+
- **Make `project.recruit` a `system: true` atom.** Rejected: that would keep it out of custom ranks, which is how the spec handles letting one particular builder recruit.
|
|
102
|
+
- **Swap `project.curate` for `project.recruit` on the hub directory**, as ADR 0210 and 0215 predicted. Rejected: it would raise a gate on a viewing surface (see D3.6).
|
|
103
|
+
|
|
104
|
+
## Consequences
|
|
105
|
+
|
|
106
|
+
- ADR 0141's Status line now says its §5 invitations paragraph is superseded here. §1–§4 and the §5 catalog/Explore paragraph still stand.
|
|
107
|
+
- The prediction in ADR 0210, 0215 and 0248 is revised for the hub directory and the hub invite. The sliver half of it is kept, and R3 carries it out.
|
|
108
|
+
- Two new invariants, both checked in `tests/project_admin_console_model.mjs`: `kind` and `created_by` never enter an admission read, and no hub source writes a project's admission row. The same file checks `project.recruit`'s shape once R3 mints it.
|
|
109
|
+
- This ADR changes no running behaviour. R2 costs one additive migration. R3 costs a grant migration and the gate moves.
|
|
110
|
+
- **Still open, and owned elsewhere:**
|
|
111
|
+
- Until R6 lands, the wizard's invite step creates a hub invite that lets nobody in.
|
|
112
|
+
- The 30-day invite expiry is task 1002969.
|
|
113
|
+
- The private-account sliver inside the instance's own application queue remains limited by ADR 0201 D2.7: the hub cannot check an instance's permission from a cross-origin browser request.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# ADR 0336 — A guild is a hub-side group whose members each consent twice, and its collective record is only ever the sum of what those members already show
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted for D1–D4 and D6–D8. **D5 (the collective record) is a recommendation awaiting the owner's ratification** before [task 1002302](https://cloudbongos.com/builders#/task/1002302) (R26) builds it.
|
|
4
|
+
- **Date:** 2026-09-24
|
|
5
|
+
- **Task:** [task 1002299](https://cloudbongos.com/builders#/task/1002299) (R23, goal 1000110 — which folds in goal 1000045's criteria C7/C8/C9)
|
|
6
|
+
- **Namespace:** `/g/<slug>`
|
|
7
|
+
- **Relates to:** [ADR 0171](0171-profile-rollup-join-and-consent.md) D2/D4 (the leak-by-subtraction ban, read-time rollup, `no-store`), [ADR 0202](0202-connections-graph-and-bounded-degree.md) D2/D4/D5 (the lifecycle reused here, "every count is the length of the list you may see", the reserved guild slot), [ADR 0207](0207-connections-implementation-departures-and-honest-limits.md) D3/D4 (one predicate file; own-scope floors on consent), [ADR 0201](0201-cross-project-application-vouched-row-live-record.md) + [ADR 0260](0260-the-application-is-the-consent-and-the-echo-is-the-gate.md) (the application is the project's own row; applying is consent to be seen), [ADR 0215](0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md) D1 (a stale consent must not activate on a widening), [ADR 0174](0174-government-terminology-ranks-not-roles.md) (`role` means a craft), [ADR 0255](0255-a-public-lists-ordering-is-part-of-its-payload.md) (a public list may be ordered only by what it publishes), `docs/specs/<redacted>.md` (D1/D2 matrix; its Deferred guild bullet), `docs/specs/<redacted>.md` (the reserved project-slug list), `docs/specs/<redacted>.md` (the owner's guild decisions)
|
|
8
|
+
|
|
9
|
+
> ADR **0332** is held by three sibling sessions in flight at the time of writing (tasks 1002285, 1002813, 1003338), and the numbers after it will go to them as they renumber; this ADR takes 0336 rather than race them. The gap is deliberate (the ADR 0199 / 0202 / 0215 precedent).
|
|
10
|
+
|
|
11
|
+
## Context
|
|
12
|
+
|
|
13
|
+
The owner's interview fixed four things about guilds and left the model to this spike: guilds are **open to any builder** to create or join, a builder may belong to **several**, visibility is the **guild owner's choice of public or unlisted** (unlisted stays off the map and search), the collective record **excludes hidden members and says "N of M members counted"**, and a guild engages a project **only with each member's own opt-in**. Five facts found in the tree decide the rest.
|
|
14
|
+
|
|
15
|
+
1. **No platform guild exists.** Every "guild" in the repo is Discord's word for a Discord server. There is nothing to extend and nothing to migrate.
|
|
16
|
+
2. **The social layer is account-global and lives on the hub.** Accounts, connections, the activity rollup and the application echo are all `platform_identity_*` tables keyed on `github_id`, in a module that is `default: false` everywhere except the hub. Core `goal_members` is per-instance and cannot say "these people are a group across projects".
|
|
17
|
+
3. **The privacy machinery is finished and centralised.** `modules/platform-identity/account-visibility.js` is the one home for every effective-visibility answer (existence, consent, numbers, reach), and `tests/effective_visibility_predicate.mjs` fails any read of `platform_identity_accounts` that does not join one of them. A guild feature either composes those predicates or it forks the matrix.
|
|
18
|
+
4. **The tension the privacy spec deferred here is real but narrower than it looks.** "N of M counted" is a leak by subtraction *only if* M counts someone the reader could not otherwise see, or the total includes someone whose own record is not public. With both fixed, it is not a leak at all (D5).
|
|
19
|
+
5. **Admission is per person, on the project, and already has one door.** ADR 0201 made an application the project's own `access_requests` row, vouched by the hub; the hub's individual invite (`POST /projects/invite` → `inviteToProject`) pre-seeds a pending membership the invitee accepts. A guild is a group of people, and a project still admits people.
|
|
20
|
+
|
|
21
|
+
Nothing in this feature confers authority. A guild membership grants no rank, no credits, no admission and no access to any project (ADR 0016 / ADR 0141).
|
|
22
|
+
|
|
23
|
+
## Decision
|
|
24
|
+
|
|
25
|
+
### D1 — Three hub tables in `platform-identity`, in their own files, distinct from `goal_members`
|
|
26
|
+
|
|
27
|
+
A guild is account-global, so it belongs beside connections: `modules/platform-identity/guilds.js` (domain) and `routes/guilds.js`, **not** `platform-identity.js`, which sits at its 1,500-line ratchet. Migration sketch, authored by [task 1002300](https://cloudbongos.com/builders#/task/1002300) (R24) at the next free `platform_identity_` number, additive only (ADR 0083 §5):
|
|
28
|
+
|
|
29
|
+
```sql
|
|
30
|
+
CREATE TABLE IF NOT EXISTS platform_identity_guilds (
|
|
31
|
+
id bigserial PRIMARY KEY,
|
|
32
|
+
slug text NOT NULL, -- the /g/<slug> key; fixed at creation (D6)
|
|
33
|
+
name text NOT NULL, -- display name; renamed freely
|
|
34
|
+
description text,
|
|
35
|
+
-- No DEFAULT: the creator chooses. A silent default would decide a privacy question for them.
|
|
36
|
+
visibility text NOT NULL CHECK (visibility IN ('public', 'unlisted')),
|
|
37
|
+
-- No creator column: the owner row below is the authority, and a permanent
|
|
38
|
+
-- "created by" would keep a departed creator's tie to the guild (D2's hard-delete rule).
|
|
39
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
40
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
41
|
+
);
|
|
42
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_identity_guilds_slug
|
|
43
|
+
ON platform_identity_guilds (lower(slug));
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS platform_identity_guild_members (
|
|
46
|
+
guild_id bigint NOT NULL REFERENCES platform_identity_guilds(id) ON DELETE CASCADE,
|
|
47
|
+
github_id bigint NOT NULL,
|
|
48
|
+
-- membership_kind, never `role`: ADR 0174 reserves that word for the three crafts.
|
|
49
|
+
membership_kind text NOT NULL DEFAULT 'member' CHECK (membership_kind IN ('owner', 'member')),
|
|
50
|
+
-- This member agreed to appear on the guild's PUBLIC page (D4). Not the guild's visibility.
|
|
51
|
+
shown_publicly boolean NOT NULL,
|
|
52
|
+
joined_at timestamptz NOT NULL DEFAULT now(),
|
|
53
|
+
PRIMARY KEY (guild_id, github_id)
|
|
54
|
+
);
|
|
55
|
+
-- Exactly one owner per guild, held by the database rather than a route.
|
|
56
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_identity_guild_owner
|
|
57
|
+
ON platform_identity_guild_members (guild_id) WHERE membership_kind = 'owner';
|
|
58
|
+
CREATE INDEX IF NOT EXISTS <redacted>
|
|
59
|
+
ON platform_identity_guild_members (github_id);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS platform_identity_guild_requests (
|
|
62
|
+
id bigserial PRIMARY KEY,
|
|
63
|
+
guild_id bigint NOT NULL REFERENCES platform_identity_guilds(id) ON DELETE CASCADE,
|
|
64
|
+
github_id bigint NOT NULL, -- the person who would join
|
|
65
|
+
kind text NOT NULL CHECK (kind IN ('invite', 'request')),
|
|
66
|
+
status text NOT NULL DEFAULT 'pending'
|
|
67
|
+
CHECK (status IN ('pending', 'accepted', 'rejected', 'cancelled', 'expired')),
|
|
68
|
+
seen_at timestamptz,
|
|
69
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
70
|
+
resolved_at timestamptz
|
|
71
|
+
);
|
|
72
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_identity_guild_requests_open
|
|
73
|
+
ON platform_identity_guild_requests (guild_id, github_id) WHERE status = 'pending';
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A fourth table, the guild's engagement with a project, is D7's and lands with [task 1002303](https://cloudbongos.com/builders#/task/1002303) (R27), not R24. A standalone or self-hosted instance has none of these tables and shows no guild surface: the goal spec's "degrade to local-only" rule, free.
|
|
77
|
+
|
|
78
|
+
**Rejected.** *A core table beside `goal_members`*: it would exist empty on every self-hosted instance and breach the module boundary (the ADR 0202 D1 reason). *Reusing `goal_members`*: a goal is an instance-local work container and a guild is a cross-project group of people. They share a word ("member") and nothing else.
|
|
79
|
+
|
|
80
|
+
### D2 — Two membership kinds: one owner, and members. Neither is authority.
|
|
81
|
+
|
|
82
|
+
- **The owner** (exactly one, the creator until they transfer) invites, accepts or declines join requests, removes members, sets visibility, edits name and description, files a guild application (D7), and dissolves the guild. Transfer is **two statements in one transaction**: demote the old owner, then promote the new one. A single `UPDATE` that swaps both rows would fail or succeed depending on the order Postgres visits them, because a unique index is checked row by row and a partial one cannot be deferred.
|
|
83
|
+
- **A member** leaves, sets their own `shown_publicly`, and opts in or out of each engagement.
|
|
84
|
+
- **Leaving:** any member may leave. The owner must transfer first, unless they are the last member, in which case leaving **dissolves** the guild. Removing a member, leaving and dissolving are hard deletes (cascading to open requests): a tombstone saying "A was removed from G" is data nobody consented to keep, the ADR 0202 D2 reason.
|
|
85
|
+
|
|
86
|
+
A third "officer" kind is **deferred, not refused**. Nothing in C7–C10 needs a delegate yet, and adding one later is a widened `CHECK` with no data migration. The task title says "roles"; in the schema the word is `membership_kind` (ADR 0174/0177).
|
|
87
|
+
|
|
88
|
+
### D3 — Joining takes two consents, through the connections lifecycle verbatim
|
|
89
|
+
|
|
90
|
+
No self-join. A guild's record and roster are its reputation, and a builder's guild list is theirs, so **nobody is added to a guild they did not accept, and no guild gains a member it did not admit.** Two directions, one table:
|
|
91
|
+
|
|
92
|
+
- **Invite** (owner → a builder): the invitee accepts or declines.
|
|
93
|
+
- **Request** (a builder → a *public* guild): the owner accepts or declines. An unlisted guild takes no requests, because a stranger cannot address it (D4).
|
|
94
|
+
|
|
95
|
+
The lifecycle is ADR 0202 D2's, reusing its exported constants so the two features cannot drift: `pending → accepted | rejected | cancelled | expired`; **a decline is silent**, and a re-ask of the same pair inside 90 days is swallowed with the same response a real one gets; expiry is 30 days, read-time, with the expired row resolved in the same statement that inserts the next one; accept resolves the request and inserts the membership in **one statement**. **`accountActiveSql` floors the whole feature**: a provisional or terms-less account cannot create, request, be invited, or appear anywhere (ADR 0171 D3, ADR 0213). A private account *can* join; D4 decides where it shows.
|
|
96
|
+
|
|
97
|
+
R35 (a cluster invited to form a guild) needs a batch. R24's invite route takes a **bounded list** (at most 25, a named constant), and each entry is an ordinary individual invite. A batch is a loop, never a new kind of membership.
|
|
98
|
+
|
|
99
|
+
### D4 — `public | unlisted` is the guild's choice. Being *shown* is each member's.
|
|
100
|
+
|
|
101
|
+
- **A public guild** has a public page (`GET` by slug, viewer-independent, `no-store`), appears on the map and in search, and takes join requests.
|
|
102
|
+
- **An unlisted guild** has no public existence. Its public read answers **404 to everyone, its own members included**, exactly like a private account's profile. Its members reach it through an own-scope read, which is the privacy spec D2 page-shell composition. It takes invites only. **Rejected: "unlisted = anyone with the link".** A slug is a human word and guessable, so a link-reachable unlisted roster is public to anyone with a dictionary.
|
|
103
|
+
|
|
104
|
+
**The public roster is `shown_publicly AND accountVisibleSql(a)`**, composed from the shared predicate and never restated. Three consequences:
|
|
105
|
+
|
|
106
|
+
1. **A private account is never on any public roster**, whatever its flag. Rendering it there is "the 404 bypass wearing a different hat" (ADR 0202 D4 §2). Its fellow members still see it in the own-scope read, because joining is consent to be seen **by the guild**, never by the world (the ADR 0260 D2 shape).
|
|
107
|
+
2. **`hide_stats` never hides membership.** It suppresses the numbers beside a name, not the name (ADR 0202 D4 §3).
|
|
108
|
+
3. **Widening a guild does not widen its members.** `shown_publicly` is set `true` by joining a guild that is public at that moment (the invite or request card says so), and `false` by joining an unlisted one. **The consent is to what the card showed, not to what the guild became.** An accept carries the visibility its card displayed, and if the guild's visibility has changed since, the accept is refused (409) and the card re-renders for a fresh decision. Otherwise an owner could flip to public between the card and the click and publish someone who agreed to an unlisted guild. A request is only ever made to a public guild, so its consent to be shown is given when it is sent. The member may flip it at any time. **Changing the guild's visibility changes nobody's flag except the owner who flipped it.** So an unlisted guild that goes public shows only its owner until each member agrees, and each member is asked in their inbox. This is ADR 0215 D1's lesson in reverse: consent given to a small audience must not quietly become consent to a large one.
|
|
109
|
+
|
|
110
|
+
Every count is the length of a list the reader can see (ADR 0202 D4). The guild's public **size** is the length of its public roster. There is never a "+N more".
|
|
111
|
+
|
|
112
|
+
### D5 — The collective record counts a member if and only if that member's own record is public right now *(recommendation, for ratification)*
|
|
113
|
+
|
|
114
|
+
**The rule:** a member is **counted** iff `shown_publicly AND rollupVisibleSql(a)`: they agreed to be on this guild's public page, **and** their own cross-project record is publicly visible (active, public, not hidden). The record is computed **at read time, on every read**, sent `no-store`, and never stored, cached or snapshotted. It shows:
|
|
115
|
+
|
|
116
|
+
| Field | Computed as | Why it is safe |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| credits, works shipped, karma | sums over the **counted** members | each addend is already on that member's own public profile |
|
|
119
|
+
| disciplines | the union over the counted members | same |
|
|
120
|
+
| projects | the **named** public projects the counted members build on (`onMapSql AND visibility = 'public'`, the ADR 0171 D2 amendment rule) | a profile already names exactly these |
|
|
121
|
+
| "**N of M** counted" | N = counted, M = the public roster's length | both are lengths of lists the reader can see |
|
|
122
|
+
|
|
123
|
+
**Deliberately absent:** a distinct count of *all* the members' projects, non-public ones included. Unioning counts that profiles only give as `more_projects` would reveal that two members share a private project (3 + 3 projects, 5 distinct). That fact appears on no profile.
|
|
124
|
+
|
|
125
|
+
**Why this dissolves the tension instead of trading it off.** Every number the guild shows is a sum of numbers each counted member already shows on their own public profile, at the same instant. So the guild record **cannot disclose anything a reader couldn't add up from the members' own profiles**. There is nothing to subtract. The M − N members who are shown but not counted are the listed members who hide their numbers, and each one's own profile already says "keeps their track record private". Joins, leaves, hides and un-hides move the total by numbers that were public at that moment, so watching the total over time learns nothing either. The two ways "N of M" *would* leak are both closed by construction. M never counts an account the reader can't see, so private members' existence does not leak through the count. The total never includes an account whose own record is not public, so a hidden or private member's numbers do not leak through subtraction.
|
|
126
|
+
|
|
127
|
+
**The two options the brief named, and why neither is recommended:**
|
|
128
|
+
|
|
129
|
+
- **All-or-nothing** (the guild shows no numbers if any member hides). Safe, but it lets any single member blank everyone's record. It puts social pressure on exactly the people the hide switch exists for ("you're the reason our guild shows nothing"), which turns a free choice into a costly one. It also still leaks one bit: a blank record means someone hides.
|
|
130
|
+
- **Per-member consent per guild** (each member opts in to being counted in this guild). For a member whose record is already public, the consent adds nothing, because their numbers are on their profile anyway. For a hidden or private member, "opting in" would mean their numbers enter a public sum while their own profile withholds them. That breaks the owner-signed rule that **`hide_stats` beats everything** (privacy spec D2), and the sum reveals them by subtraction the moment they are the only such member, or on any join or leave. So this option either does nothing or breaks the one invariant every other surface keeps. What survives of it is the part that only **narrows**: `shown_publicly` (D4) lets a member stay in a guild without being on its page or in its numbers.
|
|
131
|
+
|
|
132
|
+
**The trade-off, stated plainly for the owner:** a guild's public numbers and its public size **understate** it whenever members hide their stats, keep their accounts private, or haven't agreed to be shown. A guild of privacy-minded builders will look smaller and rank lower on the map (D8) than it really is. In return, no member's choice is ever overridden or vetoed by another member's, the guild page can never be used to recover a hidden record, and the rule needs no new privacy setting. The only new flag is D4's `shown_publicly`, which is about the guild page and not about numbers.
|
|
133
|
+
|
|
134
|
+
**Honest limit:** because the record is derivable from public profiles, it carries no information of its own. It is a convenience sum and a ranking input, and it must be described that way. It is also not a record of work done *as a guild*. The goal's C7 says "collective track record", and this is the members' combined record, not a joint one. A true joint record (work shipped by a guild's engagement on a project) is a different feature and is deferred.
|
|
135
|
+
|
|
136
|
+
### D6 — The namespace is `/g/<slug>`, reserved in the SEO spec, and the slug is fixed at creation
|
|
137
|
+
|
|
138
|
+
The guild page lives at **`/g/<slug>`** on the hub, the sibling of `/u/<handle>`. The word **`g`** joins the reserved project-slug list in `docs/specs/<redacted>.md` (its Routing bullet), the way `u` guards the profile namespace, per [task 1002912](https://cloudbongos.com/builders#/task/1002912)'s rule. The word is reserved and **no route is squatted**. The page itself is [task 1002301](https://cloudbongos.com/builders#/task/1002301)'s (R25). `tests/guild_namespace_reserved.mjs` fails if this ADR's `Namespace:` line and the spec's reserved list disagree. The guild API mounts under the module's API prefix like every other hub route, so it needs no top-level word.
|
|
139
|
+
|
|
140
|
+
- **Slug:** `normalizeHandle`'s charset (lowercase letters, digits and inner hyphens, at most 39 characters) with a two-character floor, unique case-insensitively, owner-picked at creation, and refused if it is a word the `/g` namespace uses (`me, new, edit, settings, admin, api`, exported as a constant like `RESERVED_HANDLES`).
|
|
141
|
+
- **Fixed in V1.** The display name changes freely and the slug does not, so there is no 301 machinery to build. A rename with a previous-slug window (the handle precedent) is a named follow-up.
|
|
142
|
+
- **Search engines:** a public guild page may be indexed. Adding guilds to the sitemap is the SEO goal's call (its R10/R15 pattern), not this tree's. An unlisted guild has no page to index.
|
|
143
|
+
|
|
144
|
+
### D7 — A guild engages a project only through the individual door, one person at a time
|
|
145
|
+
|
|
146
|
+
A project still admits **people**, through the one door ADR 0201 built, so no new admission path exists. The hub holds a guild-level **engagement** row: `(guild_id, client_id, kind ∈ {'apply','invited'}, created_at)`. It records no filer, for the same reason the guild row keeps no creator. It is **statusless**, for the ADR 0201 D1.3 reason (the hub may not hold an admission fact about someone else's project), and it ages out read-time at 30 days.
|
|
147
|
+
|
|
148
|
+
- **A guild applies** (the owner files it). Each member sees "G is applying to P — apply with them?" in their own inbox. **Opting in is applying as yourself** through the existing relay (`POST /my-projects/join`, vouched, echoed), with the engagement attached to that member's application echo. Nobody reaches P without their own act. P receives ordinary vouched applications and approves each with `access_request.review`, exactly as today. The **guild label reaches P's queue through the live applicant-profile read** (ADR 0260's `POST /sso/applicant-profile`, which already reads the echo), returning `guild: { slug, name }` when the echo carries an engagement. There is no new body field, header or instance column, so a project on any core pin keeps working (the ADR 0201 D2.2 compatibility rule). For a public guild, the reviewer may open its public page. For an unlisted guild, the reviewer sees its name and the applying members' own applicant views, and nothing else.
|
|
149
|
+
- **A project invites a guild** (whoever passes P's existing inviter gate, `requireProjectInviter`). The hub fans out **ordinary individual invites**, through the same primitive used to invite one builder (today `inviteToProject`), **to exactly the members on G's public roster at that moment**: the people the inviter could see. Each accepts or declines their own. **Nobody who joins G later inherits the invite, and members the inviter could not see are not invited.** The alternative, a guild-level pre-approval, would let a guild owner decide who gets admitted to someone else's project just by editing their own roster. That is the inversion ADR 0141 exists to prevent. The engagement row (`kind = 'invited'`) lets the members who were *not* invited (private or not shown) see "P invited this guild" in their own-scope view and apply themselves, landing as ordinary applications. **That notice names P only when P is on the map and Public**, the rule that decides which projects a profile may name (`onMapSql AND visibility = 'public'`, the ADR 0171 D2 amendment). A Private or Stealth project's invite reaches the people it chose and nobody else, because telling members P never saw that P is recruiting would let the roster widen P's reach. An unlisted guild cannot be invited, because it cannot be addressed.
|
|
150
|
+
|
|
151
|
+
### D8 — The map ranks public guilds by two published numbers, computed at read time
|
|
152
|
+
|
|
153
|
+
**Who:** `visibility = 'public'` and a non-empty public roster. **Two orderings, no stored score:** **size** = M, and **shipped** = the counted members' summed works shipped (D5). Ties break on `slug`. Every sort key is a number the row itself publishes (ADR 0255: a public list's order is a covert column otherwise), and nothing ranks on a hidden input. The read is `no-store` and paged like the projects feed. **Cost:** one aggregate over public-guild memberships joined to the activity rollup, per read. At the hub's current scale (hundreds of accounts) that is a few index scans. The revisit trigger is a measured p95, and any cache added then must be invalidated by a hide, a privacy flip, a `shown_publicly` change, a membership change and a visibility change. Otherwise a stale rank position would keep publishing a withdrawn number (the ADR 0202 D3 reason). The ranking inputs are decided here. [Task 1002305](https://cloudbongos.com/builders#/task/1002305) (R29) keeps the visual approach, plus whether a combined score earns its place. Any such score may use only M and the D5 totals.
|
|
154
|
+
|
|
155
|
+
## What this unblocks, and how each task's scope changes
|
|
156
|
+
|
|
157
|
+
| Task | Scope as filed → scope after this ADR |
|
|
158
|
+
|---|---|
|
|
159
|
+
| [task 1002300](https://cloudbongos.com/builders#/task/1002300) R24 — CRUD + membership + visibility | Adds: D1 migration (three tables, single-owner index, `shown_publicly`, `membership_kind` not `role`); D2 owner/member + transfer (two statements, one transaction) + last-member dissolve; D3 two-consent join, the connections lifecycle and constants reused, **no self-join**, **unlisted takes invites only**, batch invite (≤ 25); D4 public read 404s unlisted to everyone, own-scope read for members, `shown_publicly` own-scope toggle, and an accept that carries its card's visibility (409 if it changed); D6 slug rules + reserved words, slug immutable; new files `guilds.js` + `routes/guilds.js`, every route in `tests/own_scope_guard.mjs`, every account read joining `account-visibility.js`. |
|
|
160
|
+
| [task 1002301](https://cloudbongos.com/builders#/task/1002301) R25 — guild page UI | Page at `/g/<slug>`; public roster per D4 (handle, display name, avatar, kind; never `github_id`/login); "N of M" per D5; members' view via own-scope call; the "show me on this guild's public page?" prompt after an unlisted→public flip. |
|
|
161
|
+
| [task 1002302](https://cloudbongos.com/builders#/task/1002302) R26 — collective rollup | **Retitle:** "counts only members whose own record is public" replaces "excludes hidden members". Read-time, never stored, `no-store`; counted = `shown_publicly AND rollupVisibleSql`; projects = named public projects only, **no union count of non-public projects**. **Blocked on the owner ratifying D5.** |
|
|
162
|
+
| [task 1002303](https://cloudbongos.com/builders#/task/1002303) R27 — guild applications + invites | Adds the engagement table (D7). Apply = each member's own vouched application through the existing relay, tagged via the echo. Invite = individual invites fanned out to the public roster at that moment, **never** a guild-level pre-approval; the "P invited this guild" notice names P only when P is on the map and Public. The guild label travels on the applicant-profile read, never a new instance field. |
|
|
163
|
+
| [task 1002304](https://cloudbongos.com/builders#/task/1002304) R28 — owner view of guild applications | **Dependency fix:** it depends on R11 ([task 1002287](https://cloudbongos.com/builders#/task/1002287)), which is **abandoned** (merged into [task 1002332](https://cloudbongos.com/builders#/task/1002332), which shipped), so R28 can never auto-promote. Repoint that edge to 1002332. Scope: group vouched rows by the guild label; "approve all" = N individual approvals. |
|
|
164
|
+
| [task 1002305](https://cloudbongos.com/builders#/task/1002305) R29 — ranking + viz spike | **Narrows to the visual approach.** The ranking inputs, audience and ordering are decided in D8. Must also confirm which orb surface exists, since goals 1000044 and 1000049, which it names, are both **archived**. |
|
|
165
|
+
| [task 1002306](https://cloudbongos.com/builders#/task/1002306) R30 — map API | Public guilds with a non-empty public roster; size and shipped orderings per D8; slug tiebreak; read-time, `no-store`, paged. |
|
|
166
|
+
| [task 1002307](https://cloudbongos.com/builders#/task/1002307) R31 — map UI | No change beyond R29's archived-goal risk. |
|
|
167
|
+
| [task 1002308](https://cloudbongos.com/builders#/task/1002308) R32 — PROOF | Adds negatives: a hidden member lowers N but not M; a private member moves neither and is absent from the roster; an unlisted guild is absent from the map and 404s publicly; after an unlisted→public flip, a member who has not agreed is not shown, and an invite accepted from a card that predates the flip is refused; someone who joins after a project invites the guild gets no invite. |
|
|
168
|
+
| [task 1002309](https://cloudbongos.com/builders#/task/1002309) R33 — proximity spike | A guild co-membership may feed a closeness signal **only where the viewer can see both memberships**: the viewer's own guilds, or public rosters. An unlisted guild never shapes what a non-member sees. |
|
|
169
|
+
| [task 1002311](https://cloudbongos.com/builders#/task/1002311) R35 — cluster → guild | Uses R24's batch invite. Forming a guild from a cluster is create + invite, and each invitee accepts individually. |
|
|
170
|
+
| [task 1002310](https://cloudbongos.com/builders#/task/1002310), [task 1002312](https://cloudbongos.com/builders#/task/1002312), [task 1002313](https://cloudbongos.com/builders#/task/1002313) — R34, R36, R37 | No change. Recruiting a cluster (R36) is individual invites to people, not a guild act, and a cluster carries no collective record. |
|
|
171
|
+
|
|
172
|
+
ADR 0202 D5's reserved "shared guilds" term in *builders you may know* ([task 1002297](https://cloudbongos.com/builders#/task/1002297)) stays at zero weight until R24 ships. When it is switched on, it obeys the same rule R33 does.
|
|
173
|
+
|
|
174
|
+
## Consequences
|
|
175
|
+
|
|
176
|
+
- The hub gains three tables (four after R27), and the feature adds **no new privacy predicate**. The roster and the count compose `accountVisibleSql` and `rollupVisibleSql`. `shown_publicly` is a membership fact, not an account axis, so the owner-signed privacy matrix stays exactly as signed.
|
|
177
|
+
- Two consents stand between a builder and every public guild surface: joining (the guild's and theirs) and being shown (theirs). The account-level flags still override both. A guild can never widen what a member's own settings allow.
|
|
178
|
+
- A guild's public numbers understate it by design (D5's trade-off), and the map inherits that. The UI copy must say "N of M counted", never a bare total.
|
|
179
|
+
- Nothing about admission moves. A project never learns of a private or unshown member unless that member applies, and a guild owner can never cause anyone to be admitted anywhere.
|
|
180
|
+
|
|
181
|
+
## Rejected
|
|
182
|
+
|
|
183
|
+
- **All-or-nothing guild stats** and **per-member consent to count hidden numbers**. See D5.
|
|
184
|
+
- **M counting every member.** With a filtered roster beside it, the difference reveals how many private members exist, the ADR 0202 D4 connection-count leak.
|
|
185
|
+
- **A stored or materialised guild total or rank.** A hide must beat every copy (ADR 0171 D2/D5).
|
|
186
|
+
- **A union count of all members' projects.** It reveals shared non-public projects.
|
|
187
|
+
- **"Unlisted" as link-reachable.** Slugs are guessable.
|
|
188
|
+
- **Self-join to a public guild.** It borrows a guild's reputation without its consent.
|
|
189
|
+
- **Carrying members' `shown_publicly` over when a guild widens to public.** That is consent given to a small audience, reused for a large one.
|
|
190
|
+
- **A guild-level pre-approved invite.** It lets a guild owner decide admission to someone else's project.
|
|
191
|
+
- **A hub guild-applications table with a status.** An admission fact on the wrong side of the boundary (ADR 0201 D1.3).
|
|
192
|
+
- **A `role` column.** ADR 0174.
|
|
193
|
+
|
|
194
|
+
## Deferred (named, not dropped)
|
|
195
|
+
|
|
196
|
+
- **An officer kind** (D2) and **open-join guilds**: both are additive once someone needs them.
|
|
197
|
+
- **Slug rename** with a previous-slug window; **guilds listed on a builder's profile** (public guilds, public roster only, when it is built).
|
|
198
|
+
- **A joint record** of work shipped by a guild's engagement on a project (D5's honest limit).
|
|
199
|
+
- **Name moderation and impersonation** ("the official … guild"): guild names are user-written public text, so R24 caps lengths and every page escapes them. A reserved-name list or a report path is a separate decision.
|
|
200
|
+
- **Per-builder caps** on guilds created or invites sent. The global write limiter stands meanwhile, the ADR 0207 deferral precedent. A real number is the owner's call.
|
|
201
|
+
- **Sitemap inclusion** of public guild pages. It belongs to the SEO goal.
|
package/docs/adr/README.md
CHANGED
|
@@ -423,3 +423,5 @@ This keeps the decision history honest and traceable.
|
|
|
423
423
|
| 0329 | [**The rendered fit check is a kit floor and an advisory ship-check row, not a grader worker** ([task 1004203](https://cloudbongos.com/builders#/task/1004203), goal 1000095 — UI quality). Nothing in the pipeline rendered a page, so titles clipped by character count ran past their cards (task 1003480, task 1004204) and a second `.mSec` child would have landed on the first (task 1003078), each found only in a browser. Two new kit floors, `text-fit` (text measured word by word past its box; SVG text past its card rect) and `sibling-overlap` (in-flow, block-level, text-bearing siblings), run in every `render.js` render; `scripts/gds/render-check.js` renders the pages a branch changed at 1280 + 390 and `/ship-check` shows them as an ADVISORY row that never flips its exit (ADR 0162). Not a grader worker: that would put a browser, fixtures and a harness on the grader host for signal the builder already saw; the artist gate (ADR 0241) stays the human look before deploy.](0329-the-rendered-fit-check-is-a-kit-floor-and-an-advisory-ship-check-row.md) | ui-design / kit + ship-check |
|
|
424
424
|
| 0330 | [**An ask is answered where it was asked** ([task 1004216](https://cloudbongos.com/builders#/task/1004216), goal 1000095 — BONGOS-V2), amending [ADR 0187](0187-collab-four-decisions.md) §4a. `lifecycle_004` let a builder ASK for help and let the **asker** mark their own row answered; the person being **asked** had no write at all, so the answer happened somewhere Bongos cannot see and the row sat open until its author remembered it — six asks addressed to the owner had been open between four and thirteen days on 2026-09-23, every one of them answerable. That was not an oversight: §4a deferred conversation to the Agora and a test named *"there is deliberately NO reply thread"* enforced it. **The deferral lost its addressee** — task 1002946 is ABANDONED and goal 1000064 ARCHIVED (verified against the live DB), so the rule had stopped meaning "wait for the Agora" and started meaning "an ask stays unanswerable forever to keep a promise to something that does not exist". **Decisions:** (1) an ask carries a thread of replies, APPEND-ONLY in the SCHEMA rather than by convention — no `updated_at`, `edited_by`, `deleted_at` or `parent_reply_id`, no reactions, no mentions, and a trigger that refuses UPDATE and DELETE outright, because a thing you can edit, nest, react to and address at people is a chat and that is what §4a declined; any builder may reply, since the board read is already project-wide and the good case is somebody you did not ask knowing the answer; (2) the **ADDRESSEE** may close an ask (`answered`), widening an asker-only UPDATE that was coherent only while nothing could be said on the row — but `withdrawn` does NOT widen, since "I no longer need this" is the asker's sentence alone and widening it would be a way to make an ask vanish unanswered; `settled_by` records which of the two closed it, and ADR 0187 §3's consent posture is untouched — closing ends a question, it does not accept work; (3) `/collab-review` posts the reply and **leaves the ask open** (owner decision), since an answer the reader has not read is exactly what an unattended run must halt on. **Consequences:** a reply cannot be taken back, by design; the FK **RESTRICTS rather than cascades** — the first draft cascaded, which reads right and was wrong, because a cascade is a plain DELETE the trigger refuses, so deleting an answered ask failed outright (caught by applying the migration to a real Postgres, not by reading it); the old test was REPLACED, not deleted, so the record that the restraint was deliberate survives; and replies inherit the pull-not-push posture, so nothing still tells an asker they were answered. Rejected: a single `answer` column (cheapest and unambiguously not a chat, but a follow-up would have nowhere to go except a second ask, splitting one conversation across two rows); waiting for the Agora (abandoned — waiting means forever); letting anyone close any ask (an open question is somebody's, and a stranger ending it should not be one click away).](0330-an-ask-is-answered-where-it-was-asked.md) | collab / lifecycle / schema |
|
|
425
425
|
| 0331 | [**The sky is the ideator's home, and the project sky takes the goal map's page** ([task 1004229](https://cloudbongos.com/builders#/task/1004229), goal 1000095 — BONGOS-V2), amending [ADR 0227](0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md) and decision 11 of `docs/specs/<redacted>.md`. The owner reviewed an interactive prototype of the ideator sky and the project sky over five rounds on 2026-09-24 and adopted it AS IS — copy, layout, colours, tour text, toggles, interactions — which contradicts two things already written down, so they are rewritten rather than leaving the build graded against a contract nobody means any more. **Decisions:** (1) the sky is the ideator's HOME surface — it replaces *Your thinking*, and the project sky replaces the goal map page — so "never load-bearing" and "arrives last" are DROPPED as a sequencing hedge from before there was a design to look at; the rule that survives at full force is the accessibility half, that the sky is never the ONLY path to anything it shows (the list drawer — the room — is one key away, and every drawn thing is a row in it); (2) ADR 0227 is AMENDED, not reversed — its measured refusal of a whole-corpus task map stands, and the project sky is §1's own prescription rather than its refusal: the goal graph is the entry point, tasks are drawn only INSIDE one goal past a zoom threshold (the per-goal drill-down §10.5 measured readable at 85.2px pitch worst case), below that they are dust density on the rings with NO identities, and the goal map's table survives as the sky's ledger drawer (§6's table equivalent kept); the nav slot the goal map was deliberately denied is GRANTED to the project sky, under Goals (owner, 2026-09-24), so part 4 changes the page and the test asserting no slot together; (3) the five states and the two channels are UNTOUCHED — criterion `ideator-sky-tells-the-truth` names a model, not a picture, and this amendment changes the picture's standing only; (4) the two HEURISTICS in the prototype — an idea's origin guessed from its text, and "born of a thought" found by scanning text for idea ids — must not ship as ledger facts: parts 3 and 4 hide both layers, part 5 replaces them with a `filed_via` column and a real born-of read. **Consequences:** `docs/design/mocks/sky/sky.template.html` is the PIXEL CONTRACT for parts 2–5, and every deviation a gate or the file ratchet forces is listed in that part's ship notes; no snapshot JSON or built page is ever committed (it carries real task and idea titles and this repo is public); ADR 0264's deleted goal "Living project sky" is superseded by this work under Working area 3. Rejected: shipping the sky as an extra and keeping "never load-bearing" (makes the adopted home surface unroutable); reversing ADR 0227 (its refusal is measured and still correct — the sky needs its drill-down shape, not its repeal); shipping the heuristics behind a disclaimer (a labelled guess is still a guess in a picture whose promise is that it cannot be faked).](0331-the-sky-is-the-ideators-home.md) | design / ideator / sky |
|
|
426
|
+
| 0335 | [**An invite is a pre-approved row on the project's own instance, and `project.recruit` says who may send one** ([task 1002813](https://cloudbongos.com/builders#/task/1002813), R1 of the project admin console wave, goal 1000110 — BONGOS-V2). Supersedes ADR 0141 §5's "Invitations (deferred slice)" paragraph only. Formalises spike task 1002786's three decisions and reconciles them with six weeks of shipping: the instance invite route (task 1003044), the PATCH rescind (ADR 0201 D4.2) and the composed join door (ADR 0247) already exist, and the hub invite's dead end is now reachable from the creation wizard. **Decisions:** (D1) authority lives on the project's own instance, and the instance IS the project; the hub is identity, relay, echo, notification and links, and never writes a project's admission row; (D2) a recruit invite is an `access_requests` row with `status='invited'`, so signing in is the acceptance act and the gate is untouched; the row gains `kind ∈ (application, invite)` and `created_by` (the inviter, which a rescind would otherwise overwrite), reuses `note`, backfills exactly (`created_at = resolved_at`), and neither column ever enters an admission read; (D3) one new grantable atom, `project.recruit` (`system:false`, floor archon, widened per instance through the existing governance tab), guards the invite, the outstanding list, the invite rescind and the queue read, while `access_request.review` keeps the verdict; because inviting is admitting, a recruiter without the review atom may not approve or re-admit an APPLICANT through the invite route (409 to the queue); the hub sliver moves onto the atom, and the hub directory and hub invite KEEP `project.curate`, revising ADR 0210/0215/0248, since swapping the directory would raise a gate on a viewing surface (ADR 0217). **Task map:** R2 (task 1002814) = the migration, the invite writes and the sender list, still on `access_request.review`; R3 (task 1002815) = mint the atom, move the gates, the 409 clause, the sliver swap and the pins, and it lands after R2; R5 (task 1002817) is already delivered, so close it into task 1002331; task 1003362 folds into R3. Rejected: a new invitations table; the hub pending row as the invite; widening `access_request.review` instead of minting an atom; a per-project settings row for the floor; letting a recruiter's invite approve applications; `system:true`; swapping `project.curate` on the hub directory.](0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md) | governance / onboarding / platform-identity |
|
|
427
|
+
| 0336 | [**A guild is a hub-side group whose members each consent twice, and its collective record is only ever the sum of what those members already show** ([task 1002299](https://cloudbongos.com/builders#/task/1002299), goal 1000110 — the R23 design spike for goal 1000045's C7/C8/C9). No platform guild existed (every "guild" in the tree is Discord's). Decisions: (1) three hub tables in `platform-identity`, in their own `guilds.js` (the 1,500-line ratchet), distinct from instance-local `goal_members`: guilds (slug, name, `visibility ∈ public|unlisted` with **no default**), members (`membership_kind ∈ owner|member`, never `role` per ADR 0174; a partial unique index holds exactly one owner; a per-member `shown_publicly`), and requests; (2) two consents to join, no self-join, through ADR 0202's connections lifecycle verbatim (silent decline, 90-day swallowed re-ask, 30-day read-time expiry); `accountActiveSql` floors the feature; an **unlisted guild 404s to everyone on the public read, members included**, and takes invites only, because a slug is guessable; (3) the public roster is `shown_publicly AND accountVisibleSql`, and widening a guild to public changes no member's flag but the flipping owner's, so consent given to a small audience is never reused for a large one; (4) **the privacy resolution, pending the owner's ratification:** a member is counted iff shown AND `rollupVisibleSql`, read-time, `no-store`, never stored, and N and M are both lengths of lists the reader can see. The guild record is therefore always a sum of numbers already on members' own public profiles, so nothing can be subtracted out. All-or-nothing is rejected (one member blanks everyone and hiding becomes costly), as is per-member consent to count hidden numbers (it breaks "hide_stats beats everything"). The trade-off: guilds of privacy-minded builders look smaller and rank lower. A union count of all members' projects is refused because it reveals shared private projects; (5) the namespace is `/g/<slug>`, `g` is reserved in the SEO spec's project-slug list, the slug is fixed in V1, and `tests/guild_namespace_reserved.mjs` pins the two documents together; (6) a guild engages a project only through the individual door: a guild application is each member's own vouched application, labelled through ADR 0260's live applicant-profile read (no new instance field); inviting a guild fans out ordinary individual invites to its public roster at that moment, **never a guild-level pre-approval** (that would let a guild owner decide admission to someone else's project); (7) the map ranks public guilds by size and counted works shipped, read-time, with a slug tiebreak, and R29 narrows to the visual. Scope changes for R24–R37 are tabled in the ADR, including R28's dependency on the abandoned R11 and R29/R31's archived orb goals.](0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md) | platform identity / privacy / social graph |
|
package/docs/api/openapi.json
CHANGED
|
@@ -24157,6 +24157,10 @@
|
|
|
24157
24157
|
"recommendation": {
|
|
24158
24158
|
"type": "string",
|
|
24159
24159
|
"maxLength": 16
|
|
24160
|
+
},
|
|
24161
|
+
"filed_via": {
|
|
24162
|
+
"type": "string",
|
|
24163
|
+
"maxLength": 16
|
|
24160
24164
|
}
|
|
24161
24165
|
},
|
|
24162
24166
|
"required": [
|
package/docs/api-reference.md
CHANGED
|
@@ -347,7 +347,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
|
|
|
347
347
|
| Method | Path | Rank | Body | Description |
|
|
348
348
|
|---|---|---|---|---|
|
|
349
349
|
| GET | `/api/bongos/inbox` | `any-builder` | — | read open ideas; skill-friction rows filtered by viewer rank. |
|
|
350
|
-
| POST | `/api/bongos/inbox` | `any-builder` | `title`, `body_md`, `kind`, `suggested_version`, `suggested_priority`, `goal_id`, `grade`, `template_key`, `field_values`, `recommendation` | for ordinary ideas; metic+ for kind='criterion-proposal'. |
|
|
350
|
+
| POST | `/api/bongos/inbox` | `any-builder` | `title`, `body_md`, `kind`, `suggested_version`, `suggested_priority`, `goal_id`, `grade`, `template_key`, `field_values`, `recommendation`, `filed_via` | for ordinary ideas; metic+ for kind='criterion-proposal'. |
|
|
351
351
|
| GET | `/api/bongos/inbox/:id` | `any-builder` | — | single idea read for the "#NNN" deep-link resolver (task 1047). |
|
|
352
352
|
| PATCH | `/api/bongos/inbox/:id` | `metic+archon` | `status`, `kind`, `promoted_to_task_id`, `reviewed_at`, `merged_into_idea_id`, `triage_note` | |
|
|
353
353
|
| PATCH | `/api/bongos/inbox/:id/body` | `any-builder` | `body_md` | Metic+ (task 360 / ADR 0018): idea triage (promote/discard/merge) shapes the backlog. |
|
package/docs/architecture.md
CHANGED
|
@@ -166,9 +166,12 @@ idea_inbox(id, title, body_md, kind, suggested_version, suggested_priority, capt
|
|
|
166
166
|
merged_into_idea_id? FK idea_inbox, triage_note?, -- mig core_195 (task 1002447): what a
|
|
167
167
|
-- triage VERDICT decided -- which idea a merge folded into, and why a discard happened.
|
|
168
168
|
-- CHECK (merged_into_idea_id IS NULL OR status = 'merged') -- no merge chains.
|
|
169
|
-
suggested_goal_id? FK goals ON DELETE SET NULL
|
|
169
|
+
suggested_goal_id? FK goals ON DELETE SET NULL, -- mig core_222 (task 1003060, goal 1000071):
|
|
170
170
|
-- an ADVISORY routing hint for rows that did NOT convert (homeless/soft-landed/Discord).
|
|
171
171
|
-- The binding home for routed work is tasks.goal_id; nothing may gate on this column.
|
|
172
|
+
filed_via? text CHECK IN ('hall'|'cli'|'discord'|'planning'|'api')) -- mig core_253_idea_filed_via (task 1004233, sky part 5):
|
|
173
|
+
-- WHICH DOOR filed it, read off the SESSION (web=hall, cli=cli, else api; 'planning' by capture.js
|
|
174
|
+
-- --via planning; the Discord bot names 'discord'). NULL = older than the column; never backfilled.
|
|
172
175
|
done_when_criteria(id, version_id, criterion_id, criterion_md, satisfied bool, satisfied_by_task_id,
|
|
173
176
|
checked_at, sort_order, UNIQUE(version_id, criterion_id)) -- 13 seeded (V1 + PMS-V2)
|
|
174
177
|
task_criteria(task_id FK tasks, criterion_id FK done_when_criteria, source 'backfill'|'manual'|'planning',
|