@bongos/core 1.19.810 → 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.
Files changed (41) hide show
  1. package/.bongos-core.json +97 -37
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +24 -0
  4. package/clients/bongos-client/index.cjs +24 -0
  5. package/clients/bongos-client/index.d.ts +39 -0
  6. package/clients/bongos-client/index.mjs +24 -0
  7. package/docs/adr/0296-a-role-speciality-is-data-over-three-existing-surfaces.md +71 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +867 -73
  10. package/docs/api-reference.md +19 -3
  11. package/docs/copy-inventory.md +48 -20
  12. package/docs/copy-registry.json +275 -23
  13. package/docs/module-api-changelog.md +4 -0
  14. package/modules/dev-box/app/src/vendor/bongos-client.cjs +24 -0
  15. package/modules/hall-ui/public/settings-specialities.js +241 -0
  16. package/modules/hall-ui/public/settings.css +56 -0
  17. package/modules/hall-ui/public/settings.html +65 -0
  18. package/modules/lifecycle/db-claim-reads.js +5 -1
  19. package/modules/memory/learnings.js +21 -2
  20. package/modules/provisioning/starter-bundles.js +1 -0
  21. package/modules/specialities/CLAUDE.md +15 -0
  22. package/modules/specialities/db.js +234 -0
  23. package/modules/specialities/migrations/specialities_001_specialities.sql +143 -0
  24. package/modules/specialities/module.json +12 -0
  25. package/modules/specialities/routes/specialities.js +379 -0
  26. package/modules/specialities/specialities.js +198 -0
  27. package/modules/ui-design/kit/serve.js +1 -1
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/scripts/gds/publish-manifest.js +1 -0
  31. package/scripts/gds/search-ingest-db.js +37 -0
  32. package/src/bongos/routes/me.js +17 -0
  33. package/src/module-api.js +1 -1
  34. package/tests/cli_exit_no_abort.mjs +45 -0
  35. package/tests/module_loader.mjs +1 -1
  36. package/tests/own_scope_guard.mjs +10 -0
  37. package/tests/session_caps.mjs +16 -1
  38. package/tests/specialities.mjs +214 -0
  39. package/tests/speciality_corpus_scope.mjs +97 -0
  40. package/tests/speciality_db_shape.mjs +124 -0
  41. 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
+ }