@bongos/core 1.19.514 → 1.19.516
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 +81 -36
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +22 -2
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/adr/0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md +157 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +362 -6
- package/docs/api-reference.md +12 -2
- package/docs/copy-inventory.md +224 -218
- package/docs/copy-registry.json +357 -291
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +12 -0
- package/modules/government/catalog.js +6 -0
- package/modules/government/migrations/government_009_mingle_manage.sql +55 -0
- package/modules/lifecycle/migrations/lifecycle_007_mingle.sql +134 -0
- package/modules/lifecycle/mingle.js +337 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/mingle.js +116 -0
- package/modules/provisioning/migrations/provisioning_014_module_selection.sql +60 -0
- package/modules/provisioning/provisioning.js +24 -4
- package/modules/provisioning/routes/provisioning.js +59 -2
- package/modules/provisioning/starter-bundles.js +91 -0
- package/modules/public-landing/public/assets/cosmos.css +1 -1
- package/modules/public-landing/public/projects.html +230 -35
- package/modules/public-landing/public/projects.probes.json +2 -2
- package/modules/public-landing/public/projects.states.json +5 -4
- package/modules/ui-design/kit/serve.js +32 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/government_seed.mjs +35 -0
- package/tests/mingle.mjs +370 -0
- package/tests/projects_hub.mjs +72 -7
- package/tests/projects_hub_module_picker.mjs +369 -0
- package/tests/provisioning_module_selection.mjs +286 -0
- package/tests/provisioning_starter_bundles.mjs +131 -0
|
@@ -1465,5 +1465,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1465
1465
|
landed since 1.19.512 with no explicit bump. run 33795977371. (task 1002620)
|
|
1466
1466
|
1.19.514 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1467
1467
|
landed since 1.19.513 with no explicit bump. run 33796959752. (task 1002620)
|
|
1468
|
+
1.19.515 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1469
|
+
landed since 1.19.514 with no explicit bump. run 33797825811. (task 1002620)
|
|
1470
|
+
1.19.516 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1471
|
+
landed since 1.19.515 with no explicit bump. run 33798954769. (task 1002620)
|
|
1468
1472
|
---------------------------------------------------------------------------
|
|
1469
1473
|
```
|
|
@@ -587,6 +587,18 @@ function createClient(opts = {}) {
|
|
|
587
587
|
// POST /memory/sync — rank: any-builder — POST /memory/sync
|
|
588
588
|
postMemorySync: (args) => request("POST", "/memory/sync", { hasBody: true }, args),
|
|
589
589
|
},
|
|
590
|
+
"mingle": {
|
|
591
|
+
// GET /mingle — rank: any-builder — GET /mingle
|
|
592
|
+
getMingle: (args) => request("GET", "/mingle", { hasBody: false }, args),
|
|
593
|
+
// PUT /mingle/opt-in — rank: any-builder — PUT /mingle/opt-in
|
|
594
|
+
putMingleOptIn: (args) => request("PUT", "/mingle/opt-in", { hasBody: true }, args),
|
|
595
|
+
// POST /mingle/pairs/{id}/decide — rank: any-builder — POST /mingle/pairs/:id/decide
|
|
596
|
+
postMinglePairsIdDecide: (args) => request("POST", "/mingle/pairs/{id}/decide", { hasBody: true }, args),
|
|
597
|
+
// PUT /mingle/project — rank: metic+archon — PUT /mingle/project
|
|
598
|
+
putMingleProject: (args) => request("PUT", "/mingle/project", { hasBody: true }, args),
|
|
599
|
+
// POST /mingle/round — rank: metic+archon — POST /mingle/round
|
|
600
|
+
postMingleRound: (args) => request("POST", "/mingle/round", { hasBody: true }, args),
|
|
601
|
+
},
|
|
590
602
|
"modules": {
|
|
591
603
|
// GET /modules — rank: any-builder — GET /modules
|
|
592
604
|
getModules: (args) => request("GET", "/modules", { hasBody: false }, args),
|
|
@@ -194,6 +194,12 @@ const PERMISSIONS = [
|
|
|
194
194
|
{ key: 'box.fleet.manage', system: false, floor: 'metic', scope: 'any', resource: 'box', guards: 'GET /boxes[/cost-ledger], POST /boxes/:b/close, PATCH .../block (ADR 0157, was archon)' },
|
|
195
195
|
{ key: 'autonomy.precheck', system: false, floor: 'metic', guards: 'GET /autonomy/precheck — unsupervised-runner gate (ADR 0157, was archon)' },
|
|
196
196
|
{ key: 'discord.channels.manage', system: false, floor: 'metic', guards: 'GET /discord/channels/plan, POST .../reconcile (ADR 0157, was archon)' },
|
|
197
|
+
// Metic by the discord.channels.manage analogue — configuring a project-wide
|
|
198
|
+
// social surface — rather than archon. The blast radius is bounded by the OTHER
|
|
199
|
+
// switch: enabling mingle offers it, and can never pair a builder who has not
|
|
200
|
+
// personally opted in (task 1003177, ADR 0187 §5), so this atom cannot put
|
|
201
|
+
// anyone in a room with a stranger on its own.
|
|
202
|
+
{ key: 'mingle.manage', system: false, floor: 'metic', guards: 'PUT /mingle/project (the project half of mingle consent), POST /mingle/round (run a pairing round) — task 1003177, goal 1000075' },
|
|
197
203
|
{ key: 'project.curate', system: false, floor: 'metic', guards: 'platform-identity: GET/POST /projects, /projects/invite, PATCH /projects/:id/featured, GET /scouting (ADR 0157, was archon)' },
|
|
198
204
|
{ key: 'override_request.decide', system: false, floor: 'metic', guards: 'GET /override-requests, POST .../:id/decide (ADR 0157, was archon)' },
|
|
199
205
|
{ key: 'claim.release.any', system: false, floor: 'metic', scope: 'any', resource: 'claim', guards: 'POST /claims/:id/release-on-behalf (ADR 0157, was archon)' },
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
-- government_009_mingle_manage.sql — grant the new `mingle.manage` atom to the
|
|
2
|
+
-- Metic and Archon rank-roles (task 1003177 / C07, goal 1000075; ADR 0187 §5).
|
|
3
|
+
--
|
|
4
|
+
-- Mingle's two operator routes — `PUT /mingle/project` (the PROJECT half of
|
|
5
|
+
-- mingle consent) and `POST /mingle/round` (run a pairing round) — need a gate,
|
|
6
|
+
-- and BV1.R105 says a modules/*/routes gate is a permission atom and never a
|
|
7
|
+
-- rank: tests/government_require_permission.mjs fails the build on a new
|
|
8
|
+
-- `requireRank`, and the two files still spelling it that way are documented
|
|
9
|
+
-- holdouts rather than a pattern to copy.
|
|
10
|
+
--
|
|
11
|
+
-- FLOOR `metic`, by the `discord.channels.manage` analogue — configuring a
|
|
12
|
+
-- project-wide social surface — rather than archon. What makes that safe is
|
|
13
|
+
-- mingle's OTHER switch: enabling mingle only OFFERS it, and the matcher can never
|
|
14
|
+
-- pair a builder who has not personally opted in (lifecycle_mingle_optin), so this
|
|
15
|
+
-- atom cannot put anyone in a room with a stranger on its own.
|
|
16
|
+
--
|
|
17
|
+
-- A BRAND-NEW key: no rank holds it yet, so BOTH rungs the floor implies are
|
|
18
|
+
-- granted here. RANK_SEED is a cumulative superset — floor `metic` means
|
|
19
|
+
-- {metic, archon} — and the drift guard (tests/government_seed.mjs) compares the
|
|
20
|
+
-- UNION of the run-once seed plus every later grant migration against that
|
|
21
|
+
-- floor-derived seed, per rank. Minting the catalog row WITHOUT this migration is
|
|
22
|
+
-- exactly what that guard caught on this task.
|
|
23
|
+
--
|
|
24
|
+
-- COPY government_004_board_permissions.sql, NOT governance_004_criterion_create.
|
|
25
|
+
-- The `governance_*` grant migrations predate government_001_rename_from_governance,
|
|
26
|
+
-- which renamed `governance_role_permissions` → `government_rank_permissions` and
|
|
27
|
+
-- its `role_key` column → `rank_key`, with NO compatibility shim. Copying one of
|
|
28
|
+
-- those files verbatim — which the first draft of THIS migration did — writes to a
|
|
29
|
+
-- table that no longer exists, so it errors at apply time on any database that
|
|
30
|
+
-- runs migrations in filename order, a fresh install included. The drift guard
|
|
31
|
+
-- above does not catch it: it is a regex over `SELECT '<rank>', unnest(ARRAY[…])`
|
|
32
|
+
-- and never looks at the target table (tests/mingle.mjs now checks that part).
|
|
33
|
+
--
|
|
34
|
+
-- Additive only: one new permission key granted to two ranks; nothing is revoked.
|
|
35
|
+
-- Idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
|
|
36
|
+
--
|
|
37
|
+
-- One block PER RANK, in the government_004 shape: the drift guard scans for
|
|
38
|
+
-- `SELECT '<rank>', unnest(ARRAY[…])` literally, so a combined
|
|
39
|
+
-- `unnest(ARRAY['metic','archon']), 'key'` form parses as zero grants and reds it.
|
|
40
|
+
|
|
41
|
+
BEGIN;
|
|
42
|
+
|
|
43
|
+
INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
44
|
+
SELECT 'metic', unnest(ARRAY[
|
|
45
|
+
'mingle.manage'
|
|
46
|
+
])
|
|
47
|
+
ON CONFLICT (rank_key, permission_key) DO NOTHING;
|
|
48
|
+
|
|
49
|
+
INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
50
|
+
SELECT 'archon', unnest(ARRAY[
|
|
51
|
+
'mingle.manage'
|
|
52
|
+
])
|
|
53
|
+
ON CONFLICT (rank_key, permission_key) DO NOTHING;
|
|
54
|
+
|
|
55
|
+
COMMIT;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
-- lifecycle_007_mingle.sql — mingle: pairing two builders who do NOT share a craft,
|
|
2
|
+
-- and only when both the project and the person said yes (task 1003177 / C07,
|
|
3
|
+
-- goal 1000075; criterion collab-mingle-two-switches, ADR 0187 §5).
|
|
4
|
+
--
|
|
5
|
+
-- TWO SWITCHES, AND THIS TABLE IS ONLY THE SECOND ONE. Consent is the project
|
|
6
|
+
-- enabling mingle AND the individual opting in. The PROJECT switch is not here —
|
|
7
|
+
-- it is `project_settings['mingle.enabled']` (migration core_231), because a
|
|
8
|
+
-- project-wide knob belongs in the project's own store and forking a second one is
|
|
9
|
+
-- what src/bongos/project-settings.js exists to prevent. This table is the
|
|
10
|
+
-- per-builder half. Both are read on every pairing; either one off means no pair.
|
|
11
|
+
--
|
|
12
|
+
-- WHY AN EXPLICIT ROW RATHER THAN A behavior_prefs KEY. Opting in to be
|
|
13
|
+
-- auto-introduced to a stranger a few times a week is a real-world social
|
|
14
|
+
-- commitment, so the record of it should say WHEN it was given and be legible on
|
|
15
|
+
-- its own — not be one key inside a jsonb blob of unrelated UI knobs. It also
|
|
16
|
+
-- keeps the write inside this module: lifecycle may READ core `builders`, but the
|
|
17
|
+
-- behaviour prefs belong to builder-settings and writing them from here would
|
|
18
|
+
-- reach across a module boundary (ADR 0083).
|
|
19
|
+
--
|
|
20
|
+
-- FAIL CLOSED, both halves. No row means NOT participating — never "hasn't said
|
|
21
|
+
-- no". `opted_in = false` is a deliberate withdrawal and is kept rather than
|
|
22
|
+
-- deleted, so the surface can tell "asked and declined the whole feature" from
|
|
23
|
+
-- "never offered it", the same distinction ADR 0171 draws between a hidden account
|
|
24
|
+
-- and one that never federated.
|
|
25
|
+
--
|
|
26
|
+
-- MODULE-OWNED and `lifecycle_`-prefixed, so fitness check 11 permits it (ADR 0083
|
|
27
|
+
-- §Decision #5). Mirrors lifecycle_004_help_requests and lifecycle_005_task_-
|
|
28
|
+
-- recommendations, its siblings under this goal.
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS lifecycle_mingle_optin (
|
|
31
|
+
builder_id bigint PRIMARY KEY REFERENCES builders(id),
|
|
32
|
+
|
|
33
|
+
-- The switch itself. See the header: absent ≠ false ≠ true.
|
|
34
|
+
opted_in boolean NOT NULL,
|
|
35
|
+
|
|
36
|
+
-- When consent was last changed. The criterion calls mingle a real-world social
|
|
37
|
+
-- commitment; a consent record with no timestamp cannot answer "since when".
|
|
38
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
-- The matcher's read: everyone currently participating.
|
|
42
|
+
CREATE INDEX IF NOT EXISTS lifecycle_mingle_optin_in_idx
|
|
43
|
+
ON lifecycle_mingle_optin (builder_id) WHERE opted_in = true;
|
|
44
|
+
|
|
45
|
+
COMMENT ON TABLE lifecycle_mingle_optin IS
|
|
46
|
+
'The per-builder half of mingle consent (task 1003177). The project half is project_settings[''mingle.enabled'']; both must be on. No row means not participating.';
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
-- ---------------------------------------------------------------------------
|
|
50
|
+
-- The pairs
|
|
51
|
+
-- ---------------------------------------------------------------------------
|
|
52
|
+
--
|
|
53
|
+
-- A PROPOSAL, never an obligation. A row here says "you two were introduced"; it
|
|
54
|
+
-- is declinable, and declining is terminal and free.
|
|
55
|
+
--
|
|
56
|
+
-- DECLINING COSTS NOTHING, AND THE SCHEMA IS WHERE THAT IS TRUE. There is no
|
|
57
|
+
-- decline counter, no cooldown column, no cost or streak field on either party,
|
|
58
|
+
-- and no karma or credit reference anywhere in this file — a table that recorded
|
|
59
|
+
-- a decline ANYWHERE BUT HERE would be the penalty the criterion forbids. This is
|
|
60
|
+
-- the same posture lifecycle_005 takes for a declined recommendation, deliberately
|
|
61
|
+
-- restated rather than cross-referenced, because the next person to add a column
|
|
62
|
+
-- reads this file and not that one.
|
|
63
|
+
--
|
|
64
|
+
-- Nothing reads `status` back into matching, either: the matcher's eligibility read
|
|
65
|
+
-- never joins this table, so a decline cannot make the decliner less likely to be
|
|
66
|
+
-- paired again. That is the "no future matching weight" half of the criterion, and
|
|
67
|
+
-- it is a property of the READ, not a promise made here.
|
|
68
|
+
|
|
69
|
+
CREATE TABLE IF NOT EXISTS lifecycle_mingle_pairs (
|
|
70
|
+
id bigserial PRIMARY KEY,
|
|
71
|
+
|
|
72
|
+
-- Ordered low/high at write time so a pair has ONE row whichever way round it
|
|
73
|
+
-- was generated, and the open-pair unique index below actually bites.
|
|
74
|
+
builder_lo bigint NOT NULL REFERENCES builders(id),
|
|
75
|
+
builder_hi bigint NOT NULL REFERENCES builders(id),
|
|
76
|
+
|
|
77
|
+
-- The crafts that made this pair legal, recorded AS MATCHED. The criterion asks
|
|
78
|
+
-- for cross-craft "chosen deliberately and recorded"; storing the two crafts
|
|
79
|
+
-- means a pair can be audited later even if a builder changes their disciplines
|
|
80
|
+
-- afterwards, which a re-derivation from builders.preferred_disciplines could
|
|
81
|
+
-- not do.
|
|
82
|
+
craft_lo text NOT NULL,
|
|
83
|
+
craft_hi text NOT NULL,
|
|
84
|
+
|
|
85
|
+
-- 'proposed' — introduced, nobody has answered
|
|
86
|
+
-- 'accepted' — someone said yes
|
|
87
|
+
-- 'declined' — someone said no. Terminal, and carries NO consequence.
|
|
88
|
+
status text NOT NULL DEFAULT 'proposed',
|
|
89
|
+
|
|
90
|
+
-- Who answered. A record of what happened, never an input to matching.
|
|
91
|
+
decided_by bigint REFERENCES builders(id),
|
|
92
|
+
decided_at timestamptz,
|
|
93
|
+
|
|
94
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
95
|
+
|
|
96
|
+
CONSTRAINT lifecycle_mingle_pairs_status_chk
|
|
97
|
+
CHECK (status IN ('proposed', 'accepted', 'declined')),
|
|
98
|
+
|
|
99
|
+
-- Pairing someone with themselves is not a pairing.
|
|
100
|
+
CONSTRAINT lifecycle_mingle_pairs_not_self_chk
|
|
101
|
+
CHECK (builder_lo <> builder_hi),
|
|
102
|
+
|
|
103
|
+
-- The ordering invariant the open-pair index depends on.
|
|
104
|
+
CONSTRAINT lifecycle_mingle_pairs_ordered_chk
|
|
105
|
+
CHECK (builder_lo < builder_hi),
|
|
106
|
+
|
|
107
|
+
-- CROSS-CRAFT IS A DATABASE CONSTRAINT, not only a matcher rule. The criterion's
|
|
108
|
+
-- first testable claim is "a pair is only ever cross-craft"; enforcing it solely
|
|
109
|
+
-- in the matcher would leave any future writer — a backfill, a second caller, a
|
|
110
|
+
-- fixture — free to violate the one property the feature is named for.
|
|
111
|
+
CONSTRAINT lifecycle_mingle_pairs_cross_craft_chk
|
|
112
|
+
CHECK (craft_lo <> craft_hi),
|
|
113
|
+
|
|
114
|
+
CONSTRAINT lifecycle_mingle_pairs_decided_chk
|
|
115
|
+
CHECK ((status = 'proposed') = (decided_at IS NULL))
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
-- One OPEN proposal per pair. Partial, so a pair CAN be introduced again in a later
|
|
119
|
+
-- round after it was settled — a permanent unique key would make a single decline
|
|
120
|
+
-- final between two people forever, which is a consequence, and declining is free.
|
|
121
|
+
CREATE UNIQUE INDEX IF NOT EXISTS lifecycle_mingle_pairs_open_uniq
|
|
122
|
+
ON lifecycle_mingle_pairs (builder_lo, builder_hi) WHERE status = 'proposed';
|
|
123
|
+
|
|
124
|
+
-- "What am I being introduced to right now" — both sides of an open pair.
|
|
125
|
+
CREATE INDEX IF NOT EXISTS lifecycle_mingle_pairs_open_lo_idx
|
|
126
|
+
ON lifecycle_mingle_pairs (builder_lo) WHERE status = 'proposed';
|
|
127
|
+
CREATE INDEX IF NOT EXISTS lifecycle_mingle_pairs_open_hi_idx
|
|
128
|
+
ON lifecycle_mingle_pairs (builder_hi) WHERE status = 'proposed';
|
|
129
|
+
|
|
130
|
+
COMMENT ON TABLE lifecycle_mingle_pairs IS
|
|
131
|
+
'A cross-craft mingle introduction (task 1003177). Declinable, and a decline is terminal, free, and never read back into matching.';
|
|
132
|
+
|
|
133
|
+
INSERT INTO schema_migrations (version) VALUES ('lifecycle_007_mingle')
|
|
134
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// modules/lifecycle/mingle.js — mingle: two switches, cross-craft, declinable
|
|
2
|
+
// without penalty (task 1003177 / C07, goal 1000075; criterion
|
|
3
|
+
// collab-mingle-two-switches, ADR 0187 §5).
|
|
4
|
+
//
|
|
5
|
+
// THE DECISION. Mingle introduces two builders who do NOT share a craft. The
|
|
6
|
+
// owner's reasoning (ADR 0187 §5) is the goal's founding premise — the person you
|
|
7
|
+
// need is often not another one of you — and it is also what keeps mingle doing
|
|
8
|
+
// something the craft-matched Collab request path does not already do.
|
|
9
|
+
// Same-craft pairing was considered and REJECTED as duplicating
|
|
10
|
+
// collab-page-matches-my-craft; do not "improve" the matcher by adding it back.
|
|
11
|
+
//
|
|
12
|
+
// TWO SWITCHES, AND ONE IS NOT CONSENT. A pair happens only when the PROJECT has
|
|
13
|
+
// enabled mingle AND the individual has opted in. Auto-pairing a person with a
|
|
14
|
+
// stranger a few times a week is a real-world social commitment, so the project
|
|
15
|
+
// enabling it for everyone is not the person agreeing to it, and a person opting
|
|
16
|
+
// in on an instance that never turned it on is not the project offering it. Both
|
|
17
|
+
// are read on every pairing, and both FAIL CLOSED: absent is "no", never
|
|
18
|
+
// "hasn't said no yet" (the ADR 0171 posture, inherited).
|
|
19
|
+
//
|
|
20
|
+
// DECLINING IS FREE, AND THAT IS A PROPERTY OF THE READ, NOT A PROMISE.
|
|
21
|
+
// "Without penalty" means declining affects no rank, no karma, no streak and no
|
|
22
|
+
// future matching weight — and the criterion insists that be verifiable rather
|
|
23
|
+
// than stated. Two things make it so, and both are structural:
|
|
24
|
+
//
|
|
25
|
+
// 1. `decline()` writes ONE row in ONE table — the pair it is settling. It
|
|
26
|
+
// touches no credit, karma, rank or streak surface, and there is no counter
|
|
27
|
+
// anywhere to touch (see lifecycle_007_mingle.sql, which says the same thing
|
|
28
|
+
// about the schema).
|
|
29
|
+
// 2. `eligibleBuilders()` — the matcher's ONLY input about who may be paired —
|
|
30
|
+
// never reads lifecycle_mingle_pairs at all. A decline therefore cannot make
|
|
31
|
+
// the decliner less likely to be paired again, because the read that decides
|
|
32
|
+
// that has no way to see it. The one thing pair history does gate is a second
|
|
33
|
+
// OPEN proposal for the same two people, which is the unique index's job and
|
|
34
|
+
// is about not double-introducing, not about declines.
|
|
35
|
+
//
|
|
36
|
+
// tests/mingle.mjs drives both against a recording pool and fails if either stops
|
|
37
|
+
// being true.
|
|
38
|
+
//
|
|
39
|
+
// THE CRAFT VOCABULARY IS NOT REDEFINED HERE. help-requests.js declares itself the
|
|
40
|
+
// one place that knows how a craft maps to `builders.preferred_disciplines`
|
|
41
|
+
// ('builder' the craft is 'engineer' the discipline), so this file consumes
|
|
42
|
+
// `craftMapSql` / `craftForDiscipline` from it rather than re-deriving. Two
|
|
43
|
+
// spellings of the same craft would silently match nobody.
|
|
44
|
+
//
|
|
45
|
+
// Reaches core only through the doorway (ADR 0083). The core tables it reads
|
|
46
|
+
// (builders) are read-only here.
|
|
47
|
+
|
|
48
|
+
'use strict';
|
|
49
|
+
|
|
50
|
+
const api = require('../../src/module-api');
|
|
51
|
+
const { pool } = api;
|
|
52
|
+
const { craftMapSql } = require('./help-requests');
|
|
53
|
+
|
|
54
|
+
// The PROJECT switch. A module-owned knob in the project's own store
|
|
55
|
+
// (project_settings, migration core_231) — deliberately NOT a second settings
|
|
56
|
+
// table, which is the thing src/bongos/project-settings.js exists to prevent, and
|
|
57
|
+
// deliberately not registered in that file's core SETTING_KEYS: the key is
|
|
58
|
+
// namespaced to this surface, and a module's knob is the module's to name.
|
|
59
|
+
const MINGLE_ENABLED_KEY = 'mingle.enabled';
|
|
60
|
+
|
|
61
|
+
const MAX_ROWS = 100;
|
|
62
|
+
|
|
63
|
+
// THE MATCHING RULE, RECORDED. The criterion asks for cross-craft to be "chosen
|
|
64
|
+
// deliberately and recorded", so the rule is data a reader can fetch (the route
|
|
65
|
+
// serves this verbatim) rather than a shape you have to infer from the SQL — the
|
|
66
|
+
// same reason closeness-weights.json exists.
|
|
67
|
+
const MATCHING = Object.freeze({
|
|
68
|
+
rule: 'cross_craft',
|
|
69
|
+
description:
|
|
70
|
+
'A pair is two builders whose crafts do not overlap at all — an engineer with '
|
|
71
|
+
+ 'an artist or an ideator, never two of the same. The premise is that the '
|
|
72
|
+
+ 'person you need is often not another one of you.',
|
|
73
|
+
// Stated because it is the road not taken, and an unexplained absence reads as
|
|
74
|
+
// an oversight to the next person holding the matcher.
|
|
75
|
+
rejected: 'same_craft — it duplicates the craft-matched Collab request path (criterion collab-page-matches-my-craft).',
|
|
76
|
+
// DISJOINT, not merely "different". A builder who is both an artist and an
|
|
77
|
+
// engineer shares a craft with every engineer, so pairing them would put two of
|
|
78
|
+
// the same in a room and call it cross-craft.
|
|
79
|
+
overlap: 'none',
|
|
80
|
+
consent: 'Both switches required: the project enables mingle AND the builder opts in. Either off means no pair.',
|
|
81
|
+
decline: 'Terminal and free — no rank, karma, streak or matching effect. The eligibility read never sees pair history.',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
function matchingModel() { return MATCHING; }
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Switch 1 — the project
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
// TRUE only for an explicit, recognised yes. Everything else — absent, blank,
|
|
91
|
+
// 'maybe', a value that was valid before someone tightened this — is NO.
|
|
92
|
+
//
|
|
93
|
+
// This is the one place mingle deliberately DIVERGES from the rot timer's posture
|
|
94
|
+
// in src/bongos/project-settings.js. That file reports a bad value on the screen
|
|
95
|
+
// of the person who can fix it, because all of its callers are read surfaces. This
|
|
96
|
+
// is a consent gate: an unreadable switch must not open, so it fails closed the
|
|
97
|
+
// way ADR 0171's consent state does. One enforces, one reports.
|
|
98
|
+
function parseEnabled(raw) {
|
|
99
|
+
if (raw == null) return false;
|
|
100
|
+
return ['true', '1', 'on', 'yes'].includes(String(raw).trim().toLowerCase());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function projectEnabled(deps = {}) {
|
|
104
|
+
const get = deps.getSetting || api.projectSettings.get;
|
|
105
|
+
let row;
|
|
106
|
+
try {
|
|
107
|
+
row = await get(MINGLE_ENABLED_KEY);
|
|
108
|
+
} catch (_) {
|
|
109
|
+
// FAIL CLOSED. An unreadable switch is not permission.
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return parseEnabled(row && row.value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function setProjectEnabled(enabled, builderId = null, deps = {}) {
|
|
116
|
+
const set = deps.setSetting || api.projectSettings.set;
|
|
117
|
+
await set(MINGLE_ENABLED_KEY, enabled ? 'true' : 'false', builderId);
|
|
118
|
+
return { key: MINGLE_ENABLED_KEY, enabled: !!enabled };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Switch 2 — the builder
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
// null = never offered / never answered. Deliberately distinct from false, which
|
|
126
|
+
// is a deliberate withdrawal — a surface that cannot tell them apart cannot ask
|
|
127
|
+
// the right question.
|
|
128
|
+
async function optInState(builderId, deps = {}) {
|
|
129
|
+
const activePool = deps.pool || pool;
|
|
130
|
+
const { rows } = await activePool.query(
|
|
131
|
+
'SELECT opted_in, updated_at FROM lifecycle_mingle_optin WHERE builder_id = $1',
|
|
132
|
+
[builderId]
|
|
133
|
+
);
|
|
134
|
+
if (!rows[0]) return { opted_in: null, updated_at: null };
|
|
135
|
+
return { opted_in: rows[0].opted_in === true, updated_at: rows[0].updated_at };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function setOptIn(builderId, optedIn, deps = {}) {
|
|
139
|
+
const activePool = deps.pool || pool;
|
|
140
|
+
const { rows } = await activePool.query(
|
|
141
|
+
`INSERT INTO lifecycle_mingle_optin (builder_id, opted_in, updated_at)
|
|
142
|
+
VALUES ($1, $2, now())
|
|
143
|
+
ON CONFLICT (builder_id) DO UPDATE
|
|
144
|
+
SET opted_in = EXCLUDED.opted_in, updated_at = now()
|
|
145
|
+
RETURNING builder_id, opted_in, updated_at`,
|
|
146
|
+
[builderId, !!optedIn]
|
|
147
|
+
);
|
|
148
|
+
return rows[0];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// BOTH switches, resolved together, for one builder. The surface that asks "can I
|
|
152
|
+
// mingle" gets back which half is off, because "no" with no reason is a dead end
|
|
153
|
+
// for the person who could turn their own half on.
|
|
154
|
+
async function mingleStateFor(builderId, deps = {}) {
|
|
155
|
+
const [project, mine] = await Promise.all([projectEnabled(deps), optInState(builderId, deps)]);
|
|
156
|
+
return {
|
|
157
|
+
enabled: project && mine.opted_in === true,
|
|
158
|
+
project_enabled: project,
|
|
159
|
+
opted_in: mine.opted_in,
|
|
160
|
+
opted_in_at: mine.updated_at,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// The matcher
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
// Everyone the project may introduce right now: opted in, active, and carrying at
|
|
169
|
+
// least one craft.
|
|
170
|
+
//
|
|
171
|
+
// IT DOES NOT JOIN lifecycle_mingle_pairs, AND MUST NOT. This read is the whole of
|
|
172
|
+
// "who may be paired", so the moment it can see a decline, declining has a
|
|
173
|
+
// matching cost and the criterion is broken. See the header.
|
|
174
|
+
//
|
|
175
|
+
// A builder with no `preferred_disciplines` produces no rows through the unnest and
|
|
176
|
+
// is therefore absent — the correct fail-closed answer, and the same one
|
|
177
|
+
// help-requests gives: belonging to a craft is a claim about yourself the system
|
|
178
|
+
// should not assume on your behalf.
|
|
179
|
+
async function eligibleBuilders(deps = {}) {
|
|
180
|
+
const activePool = deps.pool || pool;
|
|
181
|
+
const { rows } = await activePool.query(
|
|
182
|
+
`SELECT b.id::text AS builder_id,
|
|
183
|
+
array_agg(DISTINCT ${craftMapSql('d')}) FILTER (WHERE ${craftMapSql('d')} IS NOT NULL) AS crafts
|
|
184
|
+
FROM lifecycle_mingle_optin o
|
|
185
|
+
JOIN builders b ON b.id = o.builder_id
|
|
186
|
+
CROSS JOIN LATERAL unnest(b.preferred_disciplines) AS d
|
|
187
|
+
WHERE o.opted_in = true
|
|
188
|
+
AND b.status = 'active'
|
|
189
|
+
GROUP BY b.id
|
|
190
|
+
ORDER BY b.id`
|
|
191
|
+
);
|
|
192
|
+
return rows
|
|
193
|
+
.map((r) => ({ builder_id: String(r.builder_id), crafts: (r.crafts || []).filter(Boolean).sort() }))
|
|
194
|
+
.filter((b) => b.crafts.length > 0);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// PURE. Is this pair legal? Disjoint craft sets — see MATCHING.overlap.
|
|
198
|
+
function isCrossCraft(craftsA, craftsB) {
|
|
199
|
+
const a = new Set(craftsA || []);
|
|
200
|
+
if (!a.size || !(craftsB || []).length) return false;
|
|
201
|
+
return !(craftsB || []).some((c) => a.has(c));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// PURE. Given eligible builders, produce cross-craft pairs — each builder in at
|
|
205
|
+
// most one pair. Greedy over a stable order so the same input always yields the
|
|
206
|
+
// same pairing (a matcher that shuffles cannot be tested, and cannot be explained
|
|
207
|
+
// to the person it introduced).
|
|
208
|
+
//
|
|
209
|
+
// `excludePairs` is the set of "lo:hi" keys that already hold an OPEN proposal. It
|
|
210
|
+
// is NOT decline history: settled pairs are absent from it and may be introduced
|
|
211
|
+
// again, because a decline that permanently separated two people would be a
|
|
212
|
+
// consequence, and declining is free.
|
|
213
|
+
function planPairs(eligible, { excludePairs = new Set(), limit = MAX_ROWS } = {}) {
|
|
214
|
+
const taken = new Set();
|
|
215
|
+
const out = [];
|
|
216
|
+
const list = [...eligible].sort((a, b) => String(a.builder_id).localeCompare(String(b.builder_id)));
|
|
217
|
+
for (let i = 0; i < list.length && out.length < limit; i++) {
|
|
218
|
+
const a = list[i];
|
|
219
|
+
if (taken.has(a.builder_id)) continue;
|
|
220
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
221
|
+
const b = list[j];
|
|
222
|
+
if (taken.has(b.builder_id)) continue;
|
|
223
|
+
if (!isCrossCraft(a.crafts, b.crafts)) continue;
|
|
224
|
+
const pair = orderPair(a, b);
|
|
225
|
+
if (excludePairs.has(pairKey(pair.lo.builder_id, pair.hi.builder_id))) continue;
|
|
226
|
+
taken.add(a.builder_id); taken.add(b.builder_id);
|
|
227
|
+
out.push({
|
|
228
|
+
builder_lo: pair.lo.builder_id,
|
|
229
|
+
builder_hi: pair.hi.builder_id,
|
|
230
|
+
// One representative craft each. The sets are disjoint, so any pick
|
|
231
|
+
// differs — recording them is what lets a pair be audited later even if
|
|
232
|
+
// someone changes their disciplines afterwards.
|
|
233
|
+
craft_lo: pair.lo.crafts[0],
|
|
234
|
+
craft_hi: pair.hi.crafts[0],
|
|
235
|
+
});
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function orderPair(a, b) {
|
|
243
|
+
return String(a.builder_id).padStart(24, '0') <= String(b.builder_id).padStart(24, '0')
|
|
244
|
+
? { lo: a, hi: b } : { lo: b, hi: a };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function pairKey(lo, hi) { return `${lo}:${hi}`; }
|
|
248
|
+
|
|
249
|
+
async function openPairKeys(deps = {}) {
|
|
250
|
+
const activePool = deps.pool || pool;
|
|
251
|
+
const { rows } = await activePool.query(
|
|
252
|
+
`SELECT builder_lo::text AS lo, builder_hi::text AS hi
|
|
253
|
+
FROM lifecycle_mingle_pairs WHERE status = 'proposed'`
|
|
254
|
+
);
|
|
255
|
+
return new Set(rows.map((r) => pairKey(String(r.lo), String(r.hi))));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Introduce everyone who can be introduced, and write the pairs. Refuses outright
|
|
259
|
+
// when the PROJECT switch is off — the per-builder switch is already enforced by
|
|
260
|
+
// eligibleBuilders, which only ever returns people who opted in.
|
|
261
|
+
async function runMingleRound({ limit = MAX_ROWS } = {}, deps = {}) {
|
|
262
|
+
if (!(await projectEnabled(deps))) return { paired: [], skipped: 'project_disabled' };
|
|
263
|
+
const activePool = deps.pool || pool;
|
|
264
|
+
const [eligible, open] = await Promise.all([eligibleBuilders(deps), openPairKeys(deps)]);
|
|
265
|
+
const planned = planPairs(eligible, { excludePairs: open, limit });
|
|
266
|
+
if (!planned.length) return { paired: [], skipped: null };
|
|
267
|
+
|
|
268
|
+
// ONE insert for the whole round, not one per pair. The round is bounded to
|
|
269
|
+
// MAX_ROWS and runs rarely, so this is not a hot path — but a loop of awaited
|
|
270
|
+
// INSERTs is also a partial write when the tenth throws, and unnest() makes the
|
|
271
|
+
// round atomic for free. ON CONFLICT DO NOTHING lets the open-pair unique index
|
|
272
|
+
// drop a pair another round introduced a moment ago rather than failing all of
|
|
273
|
+
// them; RETURNING is therefore what was actually written, never what was planned.
|
|
274
|
+
const { rows } = await activePool.query(
|
|
275
|
+
`INSERT INTO lifecycle_mingle_pairs (builder_lo, builder_hi, craft_lo, craft_hi)
|
|
276
|
+
SELECT * FROM unnest($1::bigint[], $2::bigint[], $3::text[], $4::text[])
|
|
277
|
+
ON CONFLICT DO NOTHING
|
|
278
|
+
RETURNING id, builder_lo::text AS builder_lo, builder_hi::text AS builder_hi,
|
|
279
|
+
craft_lo, craft_hi, status, created_at`,
|
|
280
|
+
[
|
|
281
|
+
planned.map((p) => p.builder_lo),
|
|
282
|
+
planned.map((p) => p.builder_hi),
|
|
283
|
+
planned.map((p) => p.craft_lo),
|
|
284
|
+
planned.map((p) => p.craft_hi),
|
|
285
|
+
]
|
|
286
|
+
);
|
|
287
|
+
return { paired: rows, skipped: null };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// The open introductions for one builder, either side of the pair.
|
|
291
|
+
async function openPairsFor(builderId, deps = {}) {
|
|
292
|
+
const activePool = deps.pool || pool;
|
|
293
|
+
const { rows } = await activePool.query(
|
|
294
|
+
`SELECT p.id, p.builder_lo::text AS builder_lo, p.builder_hi::text AS builder_hi,
|
|
295
|
+
p.craft_lo, p.craft_hi, p.status, p.created_at,
|
|
296
|
+
lo.github_login AS lo_login, lo.display_name AS lo_name,
|
|
297
|
+
hi.github_login AS hi_login, hi.display_name AS hi_name
|
|
298
|
+
FROM lifecycle_mingle_pairs p
|
|
299
|
+
JOIN builders lo ON lo.id = p.builder_lo
|
|
300
|
+
JOIN builders hi ON hi.id = p.builder_hi
|
|
301
|
+
WHERE p.status = 'proposed'
|
|
302
|
+
AND (p.builder_lo = $1 OR p.builder_hi = $1)
|
|
303
|
+
ORDER BY p.created_at DESC`,
|
|
304
|
+
[builderId]
|
|
305
|
+
);
|
|
306
|
+
return rows;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Accept or decline an introduction you are part of.
|
|
310
|
+
//
|
|
311
|
+
// ONE ROW, ONE TABLE. There is nothing else to write, and that absence IS the
|
|
312
|
+
// "without penalty" guarantee — see the header and the migration. Do not add a
|
|
313
|
+
// counter, a cooldown, or a karma/credit call here.
|
|
314
|
+
async function decidePair(pairId, builderId, decision, deps = {}) {
|
|
315
|
+
const activePool = deps.pool || pool;
|
|
316
|
+
if (!['accepted', 'declined'].includes(decision)) return { error: 'bad_decision' };
|
|
317
|
+
const { rows } = await activePool.query(
|
|
318
|
+
`UPDATE lifecycle_mingle_pairs
|
|
319
|
+
SET status = $3, decided_by = $2, decided_at = now()
|
|
320
|
+
WHERE id = $1
|
|
321
|
+
AND status = 'proposed'
|
|
322
|
+
AND (builder_lo = $2 OR builder_hi = $2)
|
|
323
|
+
RETURNING id, builder_lo::text AS builder_lo, builder_hi::text AS builder_hi,
|
|
324
|
+
craft_lo, craft_hi, status, decided_by::text AS decided_by, decided_at`,
|
|
325
|
+
[pairId, builderId, decision]
|
|
326
|
+
);
|
|
327
|
+
if (!rows[0]) return { error: 'not_found' };
|
|
328
|
+
return { pair: rows[0] };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
module.exports = {
|
|
332
|
+
MINGLE_ENABLED_KEY, MAX_ROWS, MATCHING, matchingModel,
|
|
333
|
+
parseEnabled, projectEnabled, setProjectEnabled,
|
|
334
|
+
optInState, setOptIn, mingleStateFor,
|
|
335
|
+
eligibleBuilders, isCrossCraft, planPairs, pairKey, openPairKeys,
|
|
336
|
+
runMingleRound, openPairsFor, decidePair,
|
|
337
|
+
};
|