@bongos/core 1.19.811 → 1.19.812
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 +95 -35
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +24 -0
- package/clients/bongos-client/index.cjs +24 -0
- package/clients/bongos-client/index.d.ts +39 -0
- package/clients/bongos-client/index.mjs +24 -0
- package/docs/adr/0296-a-role-speciality-is-data-over-three-existing-surfaces.md +71 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +867 -73
- package/docs/api-reference.md +19 -3
- package/docs/copy-inventory.md +48 -20
- package/docs/copy-registry.json +275 -23
- package/docs/module-api-changelog.md +2 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +24 -0
- package/modules/hall-ui/public/settings-specialities.js +241 -0
- package/modules/hall-ui/public/settings.css +56 -0
- package/modules/hall-ui/public/settings.html +65 -0
- package/modules/lifecycle/db-claim-reads.js +5 -1
- package/modules/memory/learnings.js +21 -2
- package/modules/provisioning/starter-bundles.js +1 -0
- package/modules/specialities/CLAUDE.md +15 -0
- package/modules/specialities/db.js +234 -0
- package/modules/specialities/migrations/specialities_001_specialities.sql +143 -0
- package/modules/specialities/module.json +12 -0
- package/modules/specialities/routes/specialities.js +379 -0
- package/modules/specialities/specialities.js +198 -0
- package/modules/ui-design/kit/serve.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/search-ingest-db.js +37 -0
- package/src/bongos/routes/me.js +17 -0
- package/src/module-api.js +1 -1
- package/tests/module_loader.mjs +1 -1
- package/tests/own_scope_guard.mjs +10 -0
- package/tests/specialities.mjs +214 -0
- package/tests/speciality_corpus_scope.mjs +97 -0
- package/tests/speciality_db_shape.mjs +124 -0
- package/tests/speciality_injection.mjs +107 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/specialities/db.js — the queries behind role sub-specialities
|
|
4
|
+
// (task 1003855; ADR 0296). The RULES live in specialities.js and are pure; this
|
|
5
|
+
// file is only the reach into Postgres, through the published doorway
|
|
6
|
+
// (src/module-api.js) per ADR 0091 §2 — never a core internal.
|
|
7
|
+
//
|
|
8
|
+
// EVERY FUNCTION TAKES AN OPTIONAL `executor`. That is the modules/lifecycle/
|
|
9
|
+
// db-deps-criteria.js precedent: the seam lets the route-level tests drive these
|
|
10
|
+
// with a fake and assert the SQL shape without a database, which matters here
|
|
11
|
+
// because the adoption path has a uniqueness rule that is easy to get wrong and
|
|
12
|
+
// expensive to discover in production.
|
|
13
|
+
|
|
14
|
+
const { pool } = require('../../src/module-api');
|
|
15
|
+
|
|
16
|
+
const exec = (executor) => executor || ((text, params) => pool.query(text, params));
|
|
17
|
+
|
|
18
|
+
const COLUMNS = `id, slug, name, discipline, summary, contract_md,
|
|
19
|
+
owner_kind, owner_builder_id, owner_project_id,
|
|
20
|
+
visibility, sellable, created_by, created_at, updated_at`;
|
|
21
|
+
|
|
22
|
+
async function createSpeciality(row, executor) {
|
|
23
|
+
const q = exec(executor);
|
|
24
|
+
const r = await q(
|
|
25
|
+
`INSERT INTO specialities_catalog
|
|
26
|
+
(slug, name, discipline, summary, contract_md, owner_kind,
|
|
27
|
+
owner_builder_id, owner_project_id, visibility, sellable, created_by)
|
|
28
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
|
29
|
+
RETURNING ${COLUMNS}`,
|
|
30
|
+
[row.slug, row.name, row.discipline, row.summary || '', row.contract_md || '',
|
|
31
|
+
row.owner_kind, row.owner_builder_id || null, row.owner_project_id || null,
|
|
32
|
+
row.visibility || 'private', !!row.sellable, row.created_by || null],
|
|
33
|
+
);
|
|
34
|
+
return r.rows[0] || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function getSpeciality(id, executor) {
|
|
38
|
+
const q = exec(executor);
|
|
39
|
+
const r = await q(`SELECT ${COLUMNS} FROM specialities_catalog WHERE id = $1`, [id]);
|
|
40
|
+
return r.rows[0] || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The catalogue read. Deliberately returns rows the viewer may NOT adopt as well
|
|
44
|
+
// as ones they may: the route filters with specialities.canAdopt so the rule
|
|
45
|
+
// lives in one pure place. Widening this SQL without touching canAdopt is the
|
|
46
|
+
// mistake this comment exists to prevent.
|
|
47
|
+
async function listVisibleTo({ builderId, projectIds = [], discipline = null }, executor) {
|
|
48
|
+
const q = exec(executor);
|
|
49
|
+
const params = [builderId, projectIds.length ? projectIds : [null]];
|
|
50
|
+
let sql = `SELECT ${COLUMNS} FROM specialities_catalog
|
|
51
|
+
WHERE visibility = 'public'
|
|
52
|
+
OR (owner_kind = 'builder' AND owner_builder_id = $1)
|
|
53
|
+
OR (owner_kind = 'project' AND visibility = 'project' AND owner_project_id = ANY($2))`;
|
|
54
|
+
if (discipline) { params.push(discipline); sql += ` AND discipline = $${params.length}`; }
|
|
55
|
+
sql += ' ORDER BY name ASC';
|
|
56
|
+
const r = await q(sql, params);
|
|
57
|
+
return r.rows;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A speciality's DISCIPLINE IS IMMUTABLE and is not in this list on purpose:
|
|
61
|
+
// moving one between crafts would silently re-scope everyone who adopted it, and
|
|
62
|
+
// the adoption rows carry a denormalised copy the migration relies on.
|
|
63
|
+
const UPDATABLE = ['name', 'summary', 'contract_md', 'visibility', 'sellable'];
|
|
64
|
+
|
|
65
|
+
async function updateSpeciality(id, patch, executor) {
|
|
66
|
+
const q = exec(executor);
|
|
67
|
+
const sets = [];
|
|
68
|
+
const params = [];
|
|
69
|
+
for (const f of UPDATABLE) {
|
|
70
|
+
if (patch[f] === undefined) continue;
|
|
71
|
+
params.push(patch[f]);
|
|
72
|
+
sets.push(`${f} = $${params.length}`);
|
|
73
|
+
}
|
|
74
|
+
if (!sets.length) return getSpeciality(id, executor);
|
|
75
|
+
params.push(id);
|
|
76
|
+
const r = await q(
|
|
77
|
+
`UPDATE specialities_catalog SET ${sets.join(', ')}, updated_at = now()
|
|
78
|
+
WHERE id = $${params.length} RETURNING ${COLUMNS}`,
|
|
79
|
+
params,
|
|
80
|
+
);
|
|
81
|
+
return r.rows[0] || null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── training (a): documents the builder supplied ────────────────────────────
|
|
85
|
+
// ON CONFLICT DO NOTHING against the (speciality_id, content_sha) index: adding
|
|
86
|
+
// the same document twice is a no-op rather than a duplicate that would double
|
|
87
|
+
// its weight in the corpus. Returns null when nothing was inserted, so the route
|
|
88
|
+
// can answer 200-already-there instead of pretending it created something.
|
|
89
|
+
async function addDocument(row, executor) {
|
|
90
|
+
const q = exec(executor);
|
|
91
|
+
const r = await q(
|
|
92
|
+
`INSERT INTO specialities_documents (speciality_id, title, body_md, source, content_sha, added_by)
|
|
93
|
+
VALUES ($1,$2,$3,$4,$5,$6)
|
|
94
|
+
ON CONFLICT (speciality_id, content_sha) DO NOTHING
|
|
95
|
+
RETURNING id, speciality_id, title, source, content_sha, added_by, created_at`,
|
|
96
|
+
[row.speciality_id, row.title, row.body_md, row.source || 'paste', row.content_sha, row.added_by || null],
|
|
97
|
+
);
|
|
98
|
+
return r.rows[0] || null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Metadata only — the bodies are the corpus and can be large. A caller that wants
|
|
102
|
+
// content asks for one document by id.
|
|
103
|
+
async function listDocuments(specialityId, executor) {
|
|
104
|
+
const q = exec(executor);
|
|
105
|
+
const r = await q(
|
|
106
|
+
`SELECT id, speciality_id, title, source, content_sha, added_by, created_at,
|
|
107
|
+
length(body_md) AS body_chars
|
|
108
|
+
FROM specialities_documents WHERE speciality_id = $1 ORDER BY created_at DESC`,
|
|
109
|
+
[specialityId],
|
|
110
|
+
);
|
|
111
|
+
return r.rows;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function getDocument(specialityId, docId, executor) {
|
|
115
|
+
const q = exec(executor);
|
|
116
|
+
const r = await q(
|
|
117
|
+
`SELECT id, speciality_id, title, body_md, source, content_sha, created_at
|
|
118
|
+
FROM specialities_documents WHERE speciality_id = $1 AND id = $2`,
|
|
119
|
+
[specialityId, docId],
|
|
120
|
+
);
|
|
121
|
+
return r.rows[0] || null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function deleteDocument(specialityId, docId, executor) {
|
|
125
|
+
const q = exec(executor);
|
|
126
|
+
const r = await q(
|
|
127
|
+
'DELETE FROM specialities_documents WHERE speciality_id = $1 AND id = $2 RETURNING id',
|
|
128
|
+
[specialityId, docId],
|
|
129
|
+
);
|
|
130
|
+
return r.rowCount > 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── training (b): what sessions taught it ───────────────────────────────────
|
|
134
|
+
async function linkLearning(specialityId, learningId, executor) {
|
|
135
|
+
const q = exec(executor);
|
|
136
|
+
const r = await q(
|
|
137
|
+
`INSERT INTO specialities_learnings (speciality_id, learning_id) VALUES ($1,$2)
|
|
138
|
+
ON CONFLICT (speciality_id, learning_id) DO NOTHING RETURNING learning_id`,
|
|
139
|
+
[specialityId, learningId],
|
|
140
|
+
);
|
|
141
|
+
return r.rowCount > 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function countTraining(specialityId, executor) {
|
|
145
|
+
const q = exec(executor);
|
|
146
|
+
const r = await q(
|
|
147
|
+
`SELECT (SELECT count(*) FROM specialities_documents WHERE speciality_id = $1) AS documents,
|
|
148
|
+
(SELECT count(*) FROM specialities_learnings WHERE speciality_id = $1) AS learnings`,
|
|
149
|
+
[specialityId],
|
|
150
|
+
);
|
|
151
|
+
const row = r.rows[0] || {};
|
|
152
|
+
return { documentCount: Number(row.documents || 0), learningCount: Number(row.learnings || 0) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── adoption ────────────────────────────────────────────────────────────────
|
|
156
|
+
// Switching craft speciality is DEACTIVATE-THEN-ACTIVATE inside one statement
|
|
157
|
+
// pair, because the partial unique index (builder_id, discipline) WHERE active
|
|
158
|
+
// would otherwise reject the second adoption. Both run on the same executor so a
|
|
159
|
+
// caller may wrap them in a transaction.
|
|
160
|
+
async function deactivateCraft(builderId, discipline, executor) {
|
|
161
|
+
const q = exec(executor);
|
|
162
|
+
await q(
|
|
163
|
+
'UPDATE specialities_adoptions SET active = FALSE WHERE builder_id = $1 AND discipline = $2 AND active',
|
|
164
|
+
[builderId, discipline],
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function adopt({ builderId, specialityId, discipline }, executor) {
|
|
169
|
+
const q = exec(executor);
|
|
170
|
+
await deactivateCraft(builderId, discipline, executor);
|
|
171
|
+
const r = await q(
|
|
172
|
+
`INSERT INTO specialities_adoptions (builder_id, speciality_id, discipline, active)
|
|
173
|
+
VALUES ($1,$2,$3,TRUE)
|
|
174
|
+
ON CONFLICT (builder_id, speciality_id)
|
|
175
|
+
DO UPDATE SET active = TRUE, discipline = EXCLUDED.discipline, adopted_at = now()
|
|
176
|
+
RETURNING builder_id, speciality_id, discipline, active, adopted_at`,
|
|
177
|
+
[builderId, specialityId, discipline],
|
|
178
|
+
);
|
|
179
|
+
return r.rows[0] || null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function abandon(builderId, specialityId, executor) {
|
|
183
|
+
const q = exec(executor);
|
|
184
|
+
const r = await q(
|
|
185
|
+
'UPDATE specialities_adoptions SET active = FALSE WHERE builder_id = $1 AND speciality_id = $2 AND active RETURNING speciality_id',
|
|
186
|
+
[builderId, specialityId],
|
|
187
|
+
);
|
|
188
|
+
return r.rowCount > 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The read the session injection path depends on: the builder's active
|
|
192
|
+
// speciality for a craft, joined to the speciality itself.
|
|
193
|
+
async function activeForCraft(builderId, discipline, executor) {
|
|
194
|
+
const q = exec(executor);
|
|
195
|
+
const r = await q(
|
|
196
|
+
`SELECT s.id, s.slug, s.name, s.discipline, s.summary, s.contract_md
|
|
197
|
+
FROM specialities_adoptions a JOIN specialities_catalog s ON s.id = a.speciality_id
|
|
198
|
+
WHERE a.builder_id = $1 AND a.discipline = $2 AND a.active
|
|
199
|
+
LIMIT 1`,
|
|
200
|
+
[builderId, discipline],
|
|
201
|
+
);
|
|
202
|
+
return r.rows[0] || null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function listAdoptions(builderId, executor) {
|
|
206
|
+
const q = exec(executor);
|
|
207
|
+
const r = await q(
|
|
208
|
+
// contract_md is HERE because the session-injection port builds the injected
|
|
209
|
+
// fragment from this row — omitting it silently dropped the expert's own terms
|
|
210
|
+
// and left only the name and summary reaching the session.
|
|
211
|
+
`SELECT s.id, s.slug, s.name, s.discipline, s.summary, s.contract_md, a.active, a.adopted_at
|
|
212
|
+
FROM specialities_adoptions a JOIN specialities_catalog s ON s.id = a.speciality_id
|
|
213
|
+
WHERE a.builder_id = $1 ORDER BY a.adopted_at DESC`,
|
|
214
|
+
[builderId],
|
|
215
|
+
);
|
|
216
|
+
return r.rows;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The craft of a task, used to decide WHICH speciality a learning belongs to.
|
|
220
|
+
// `tasks` is a CORE table, read the same way builder-settings reads core
|
|
221
|
+
// `builders` — this is not a reach into another module's own tables.
|
|
222
|
+
async function taskDiscipline(taskId, executor) {
|
|
223
|
+
const q = exec(executor);
|
|
224
|
+
const r = await q('SELECT discipline FROM tasks WHERE id = $1', [taskId]);
|
|
225
|
+
return (r.rows[0] && r.rows[0].discipline) || null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = {
|
|
229
|
+
UPDATABLE, taskDiscipline,
|
|
230
|
+
createSpeciality, getSpeciality, listVisibleTo, updateSpeciality,
|
|
231
|
+
addDocument, listDocuments, getDocument, deleteDocument,
|
|
232
|
+
linkLearning, countTraining,
|
|
233
|
+
adopt, abandon, deactivateCraft, activeForCraft, listAdoptions,
|
|
234
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
-- specialities_001_specialities.sql — role sub-specialities: a named bundle of
|
|
2
|
+
-- context, skills and an interaction contract that sits UNDER a craft
|
|
3
|
+
-- (task 1003855, goal 1000095; ADR 0296).
|
|
4
|
+
--
|
|
5
|
+
-- WHAT A SPECIALITY IS, AND WHY IT IS DATA RATHER THAN CODE. The owner's framing
|
|
6
|
+
-- is that specialities "are more or less MODULES" — but a builder authors one,
|
|
7
|
+
-- so it cannot be an ADR 0083 code module without making every subject-matter
|
|
8
|
+
-- expert ship a package. A speciality is therefore a ROW that layers over the
|
|
9
|
+
-- three surfaces the platform already has: discipline-modes.json (craft ->
|
|
10
|
+
-- playbook), behavior_prefs.interaction (the per-session interaction contract),
|
|
11
|
+
-- and the ADR 0083 manifest (declared skills). The only genuinely new part is
|
|
12
|
+
-- the knowledge bundle, which is why it gets its own table below.
|
|
13
|
+
--
|
|
14
|
+
-- OWNER RULING, 2026-09-17, recorded on the task. Training is TWO-FOLD: the
|
|
15
|
+
-- builder supplies knowledge or documentation they created (specialities_documents)
|
|
16
|
+
-- AND the speciality sharpens from session learning (specialities_learnings).
|
|
17
|
+
-- Specialities are per-builder AND per-project. Each carries a sharing decision
|
|
18
|
+
-- made by its owner, and an explicit "may be offered for sale" flag.
|
|
19
|
+
--
|
|
20
|
+
-- WHY `sellable` EXISTS WITH NO MARKETPLACE BEHIND IT. ADR 0107 records that
|
|
21
|
+
-- there is no catalog or registry and explicitly rejected building one as
|
|
22
|
+
-- premature; a marketplace is a deferred fork. The flag captures the owner's
|
|
23
|
+
-- decision AT THE MOMENT THEY MAKE IT so the deferred marketplace reads a field
|
|
24
|
+
-- that already has honest values in it, instead of asking every existing
|
|
25
|
+
-- speciality owner to answer retroactively. It grants nothing and lists nowhere.
|
|
26
|
+
|
|
27
|
+
-- ── the speciality itself ───────────────────────────────────────────────────
|
|
28
|
+
CREATE TABLE IF NOT EXISTS specialities_catalog (
|
|
29
|
+
id BIGSERIAL PRIMARY KEY,
|
|
30
|
+
slug TEXT NOT NULL,
|
|
31
|
+
name TEXT NOT NULL,
|
|
32
|
+
-- The craft this sits under. NOT a foreign key: disciplines come from
|
|
33
|
+
-- discipline-modes.json plus module contributions (enabledDisciplines()), so
|
|
34
|
+
-- the legal set is computed at runtime and a DB constraint would freeze it.
|
|
35
|
+
discipline TEXT NOT NULL,
|
|
36
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
37
|
+
-- The fragment injected into a session, exactly like behavior_prefs.interaction.
|
|
38
|
+
-- Kept as prose, not structured knobs: a speciality is an expert describing how
|
|
39
|
+
-- they want to be worked with, which is not enumerable up front.
|
|
40
|
+
contract_md TEXT NOT NULL DEFAULT '',
|
|
41
|
+
|
|
42
|
+
-- OWNERSHIP. Exactly one of builder/project owns a speciality (ruling 2).
|
|
43
|
+
owner_kind TEXT NOT NULL CHECK (owner_kind IN ('builder', 'project')),
|
|
44
|
+
owner_builder_id BIGINT REFERENCES builders(id) ON DELETE CASCADE,
|
|
45
|
+
owner_project_id BIGINT,
|
|
46
|
+
|
|
47
|
+
-- SHARING (ruling 3): the owner's decision, never a platform default.
|
|
48
|
+
-- private → only the owner sees it
|
|
49
|
+
-- project → any member of the owning project may adopt it
|
|
50
|
+
-- public → any builder on this instance may adopt it
|
|
51
|
+
visibility TEXT NOT NULL DEFAULT 'private'
|
|
52
|
+
CHECK (visibility IN ('private', 'project', 'public')),
|
|
53
|
+
-- SALE (ruling 4): records the owner's intent. See the header — no storefront.
|
|
54
|
+
sellable BOOLEAN NOT NULL DEFAULT FALSE,
|
|
55
|
+
|
|
56
|
+
created_by BIGINT REFERENCES builders(id) ON DELETE SET NULL,
|
|
57
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
58
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
59
|
+
|
|
60
|
+
-- The owner columns must agree with owner_kind, or a speciality could be owned
|
|
61
|
+
-- by nobody (invisible, unadoptable) or by both (whose sharing rule wins?).
|
|
62
|
+
CONSTRAINT specialities_catalog_owner_exactly_one CHECK (
|
|
63
|
+
(owner_kind = 'builder' AND owner_builder_id IS NOT NULL AND owner_project_id IS NULL) OR
|
|
64
|
+
(owner_kind = 'project' AND owner_project_id IS NOT NULL AND owner_builder_id IS NULL)
|
|
65
|
+
),
|
|
66
|
+
-- A speciality may only be offered for sale if someone other than its owner
|
|
67
|
+
-- could ever see it. Enforced here rather than in the route because it is a
|
|
68
|
+
-- property of the row, and a private-but-sellable row is simply incoherent.
|
|
69
|
+
CONSTRAINT specialities_catalog_sellable_needs_visibility CHECK (
|
|
70
|
+
sellable = FALSE OR visibility <> 'private'
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
-- Slug is unique PER OWNER, not globally: two experts may both have an
|
|
75
|
+
-- "economics" speciality, and a global unique would make the second one's
|
|
76
|
+
-- authoring fail with a collision against a row they cannot even see.
|
|
77
|
+
CREATE UNIQUE INDEX IF NOT EXISTS specialities_catalog_builder_slug_key
|
|
78
|
+
ON specialities_catalog (owner_builder_id, slug) WHERE owner_builder_id IS NOT NULL;
|
|
79
|
+
CREATE UNIQUE INDEX IF NOT EXISTS specialities_catalog_project_slug_key
|
|
80
|
+
ON specialities_catalog (owner_project_id, slug) WHERE owner_project_id IS NOT NULL;
|
|
81
|
+
CREATE INDEX IF NOT EXISTS specialities_catalog_discipline_idx ON specialities_catalog (discipline);
|
|
82
|
+
-- The adoptable-catalogue read: "what may I adopt", which filters on visibility.
|
|
83
|
+
CREATE INDEX IF NOT EXISTS specialities_catalog_visibility_idx ON specialities_catalog (visibility)
|
|
84
|
+
WHERE visibility <> 'private';
|
|
85
|
+
|
|
86
|
+
-- ── training half (a): knowledge the builder supplied ───────────────────────
|
|
87
|
+
-- Ruling 1a, and the part the original task did not consider at all. A document
|
|
88
|
+
-- is the expert's own material; the speciality learns the subject from it.
|
|
89
|
+
CREATE TABLE IF NOT EXISTS specialities_documents (
|
|
90
|
+
id BIGSERIAL PRIMARY KEY,
|
|
91
|
+
speciality_id BIGINT NOT NULL REFERENCES specialities_catalog(id) ON DELETE CASCADE,
|
|
92
|
+
title TEXT NOT NULL,
|
|
93
|
+
-- Content is stored inline. These are documents a person wrote or pasted, not
|
|
94
|
+
-- uploads of arbitrary size; keeping them in-row means the search chunker and
|
|
95
|
+
-- every read go through one place with no blob store to provision.
|
|
96
|
+
body_md TEXT NOT NULL,
|
|
97
|
+
source TEXT NOT NULL DEFAULT 'paste',
|
|
98
|
+
content_sha TEXT NOT NULL,
|
|
99
|
+
added_by BIGINT REFERENCES builders(id) ON DELETE SET NULL,
|
|
100
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
101
|
+
);
|
|
102
|
+
-- Re-adding the same document is idempotent rather than a silent duplicate that
|
|
103
|
+
-- would double its weight in the corpus.
|
|
104
|
+
CREATE UNIQUE INDEX IF NOT EXISTS specialities_documents_sha_key
|
|
105
|
+
ON specialities_documents (speciality_id, content_sha);
|
|
106
|
+
|
|
107
|
+
-- ── training half (b): what the speciality learned from sessions ────────────
|
|
108
|
+
-- Ruling 1b. This does NOT grow a parallel memory: a row points at an existing
|
|
109
|
+
-- `learnings` row and says "this one belongs to this speciality", so capture,
|
|
110
|
+
-- search and retention keep working exactly as they do today.
|
|
111
|
+
CREATE TABLE IF NOT EXISTS specialities_learnings (
|
|
112
|
+
speciality_id BIGINT NOT NULL REFERENCES specialities_catalog(id) ON DELETE CASCADE,
|
|
113
|
+
learning_id BIGINT NOT NULL,
|
|
114
|
+
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
115
|
+
PRIMARY KEY (speciality_id, learning_id)
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
-- ── adoption: which speciality a builder is actually working in ─────────────
|
|
119
|
+
CREATE TABLE IF NOT EXISTS specialities_adoptions (
|
|
120
|
+
builder_id BIGINT NOT NULL REFERENCES builders(id) ON DELETE CASCADE,
|
|
121
|
+
speciality_id BIGINT NOT NULL REFERENCES specialities_catalog(id) ON DELETE CASCADE,
|
|
122
|
+
-- THE CRAFT IS DENORMALISED ONTO THIS ROW ON PURPOSE. The rule is "at most one
|
|
123
|
+
-- ACTIVE speciality per builder per craft" — two contracts under one craft
|
|
124
|
+
-- would both be injected and could contradict each other, with nothing in the
|
|
125
|
+
-- session able to arbitrate. A partial unique index is the only way to enforce
|
|
126
|
+
-- that at the point of insert, and an index cannot reach through the FK to
|
|
127
|
+
-- specialities.discipline. So the craft is copied here and the unique index
|
|
128
|
+
-- below makes the rule real rather than merely documented.
|
|
129
|
+
--
|
|
130
|
+
-- Safe to copy because a speciality's discipline is IMMUTABLE: the update path
|
|
131
|
+
-- rejects a discipline change (moving a speciality between crafts would
|
|
132
|
+
-- silently re-scope everyone who adopted it). Pinned by a test.
|
|
133
|
+
discipline TEXT NOT NULL,
|
|
134
|
+
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
135
|
+
adopted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
136
|
+
PRIMARY KEY (builder_id, speciality_id)
|
|
137
|
+
);
|
|
138
|
+
-- The rule the comment above claims. Deactivating is how you switch, so an
|
|
139
|
+
-- inactive row never blocks adopting a different speciality in the same craft.
|
|
140
|
+
CREATE UNIQUE INDEX IF NOT EXISTS specialities_adoptions_one_active_per_craft
|
|
141
|
+
ON specialities_adoptions (builder_id, discipline) WHERE active;
|
|
142
|
+
CREATE INDEX IF NOT EXISTS specialities_adoptions_builder_idx
|
|
143
|
+
ON specialities_adoptions (builder_id) WHERE active;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "specialities",
|
|
3
|
+
"title": "Role sub-specialities (harnesses)",
|
|
4
|
+
"description": "Role SUB-SPECIALITIES — a named bundle of knowledge, skills and an interaction contract that sits UNDER a craft (task 1003855; ADR 0296). A craft says how you work (engineer / ideator / artist / ui); a speciality says what you work ON and how a subject-matter expert wants to be engaged inside it. Deliberately a thin layer over three surfaces that already exist — discipline-modes.json for craft->playbook, behavior_prefs.interaction for the per-session contract (a speciality contract rides that SAME GET /me -> Conductor path rather than adding a second one), and the ADR 0083 manifest for declared skills; the only new machinery is the knowledge bundle. Training is two-fold per the owner's 2026-09-17 ruling: documents the builder supplied, plus learnings captured from sessions worked in the speciality (pointing at existing `learnings` rows, never a second corpus). Owned per-builder OR per-project, each carrying its owner's sharing decision (private / project / public) and an explicit sellable flag — the flag records intent only, because ADR 0107 defers the marketplace and there is nothing to list on. default-on: a speciality is how an instance's expertise enters the platform, so it ships like the hall and the status page.",
|
|
5
|
+
"coreVersion": "^1.19.0",
|
|
6
|
+
"default": true,
|
|
7
|
+
"maintenance": { "status": "core-maintained" },
|
|
8
|
+
"migrations": true,
|
|
9
|
+
"contributes": { "routes": ["specialities"] },
|
|
10
|
+
"provides": ["specialities"],
|
|
11
|
+
"consumes": []
|
|
12
|
+
}
|