@bongos/core 1.19.249 → 1.19.251
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 +110 -30
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +14 -0
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/adr/0178-the-copy-desk-flag-and-queue.md +131 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +257 -3
- package/docs/api-reference.md +10 -1
- package/docs/copy-inventory.md +1430 -0
- package/docs/copy-registry.json +13293 -0
- package/docs/file-map.md +1 -0
- package/modules/copy-desk/CLAUDE.md +15 -0
- package/modules/copy-desk/flags.js +245 -0
- package/modules/copy-desk/migrations/copy_desk_001_flags.sql +128 -0
- package/modules/copy-desk/module.json +11 -0
- package/modules/copy-desk/registry.js +317 -0
- package/modules/copy-desk/routes/copy-desk.js +272 -0
- package/modules/copy-desk/tests/copy_flags.mjs +246 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +104 -0
- package/modules/copy-desk/tests/copy_queue.mjs +257 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +10 -0
- package/modules/hall-ui/public/copy-desk.css +119 -0
- package/modules/hall-ui/public/copy-desk.html +139 -0
- package/modules/hall-ui/public/copy-desk.js +352 -0
- package/modules/hall-ui/public/shell.js +9 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/publish-manifest.js +9 -0
- package/scripts/gds/run-unit-tests.js +11 -1
- package/src/bongos/module-scope-map.js +17 -0
- package/src/bongos/serve-internal.js +11 -1
- package/src/module-api.js +5 -1
- package/tests/copy_desk_flags_db.mjs +250 -0
- package/tests/hall_nav.mjs +7 -1
- package/tests/module_loader.mjs +5 -2
- package/tests/publish_manifest.mjs +82 -0
package/docs/file-map.md
CHANGED
|
@@ -124,6 +124,7 @@
|
|
|
124
124
|
│ ├── status-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R84, default-on): the public status dashboard at status.<apex>, moved from public-status/. NO routes/db/port — declares contributes.webSurfaces:[{host:"status.",dir:"public"}], served by the loader web-surface seam (moduleWebSurfaces → serve-internal). CLAUDE.md, module.json, public/{index.html,status.js,style.css,cursors/,snapshots/}.
|
|
125
125
|
│ ├── hall-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R85, default-on): the authenticated builders' hall at builders.<apex>, moved from public-builders/ (~13k LOC, the largest). NO routes/db/port — declares contributes.webSurfaces:[{host:"builders.",dir:"public"}] for discovery, but UNLIKE status-ui keeps its DEDICATED serving block in serve-internal (the /builders shim + canonical redirects + citizen/Archon page gates + asset-stamping + hall-widget injection), partitioned out of the generic web-surface loop and served from BUILDERS_DIR. CLAUDE.md, module.json, public/{index.html,builders.js,style.css,work,watch,harbor,gate,pair,settings,ranks,sessions,primer,diagrams,atlas,...}.{html,js},cursors/,chime.wav.
|
|
126
126
|
│ ├── lifecycle/ ← CORE-DOMAIN carve (BV1.R86, default-on): the build state machine — tasks · claims · versions · done-when · goals · dependencies · claim→ship→grade→credit · github-push + merge-lock + conflict-resolve + publish-reconciler. The LAST, most-coupled carve; registers the `lifecycle` kernel port (createTask, classifyTaskKind, tallyPeerVotes, work-tracking reads). CLAUDE.md, module.json, routes/{tasks,claims,versions,done-when,goals,dependencies,gate-approvals,analytics}.js, lifecycle.js, github-push.js, merge-lock.js, conflict-resolve.js, publish-reconciler.js.
|
|
127
|
+
│ ├── copy-desk/ ← CORE-DOMAIN module (task 1003113 / R02 of goal 1000074, default-on): the COPY half of the design contract — ADR 0081 gave design TOKENS a repo-owned source of truth, text had none. Owns the FLAG (anyone in any role marks a string or a whole surface as needing an artist's attention, with a reason required in BOTH the route and a CHECK constraint) and the QUEUE an artist reads it from. READS R01's committed registry (docs/copy-registry.json, generated by scripts/gds/copy-inventory.js) and never writes it. NO LIVE CMS is the hard non-goal: no column here could hold replacement text, and copy_no_cms.mjs pins that. Key is `copy-desk`, not `copy` — the branding contract already owns a `copy` block, so the bare word reds the "core carries no module code" fitness check. CLAUDE.md, module.json, registry.js, flags.js, routes/copy-desk.js, migrations/copy_desk_001_flags.sql, tests/{copy_flags,copy_queue,copy_no_cms}.mjs. Its hall surface lives with hall-ui (public/copy-desk.{html,js,css}), per the one-key-per-web-surface precedent.
|
|
127
128
|
│ ├── character-anim/ ← consistent-character generative animation (ADR 0088) — graded painterly frame sets + manifest, the otb-character-review skill. CLAUDE.md, module.json.
|
|
128
129
|
│ ├── provisioning/ ← stands up + operates a Cloud Bongos instance's own infra (server/DB/domain/DNS/TLS) via the API (ADR 0111); web-tier request+state only, no cloud tokens — a separate control-plane runner drains the intents. Distinct from the `hosting` module (ADR 0092), which brokers compute workloads. CLAUDE.md, module.json.
|
|
129
130
|
│ └── demo/ ← BV1.R52 acceptance-proof module: one rank-gated write route + one namespaced migration proving the loader mounts a scaffolded module with zero core edits. CLAUDE.md, module.json.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @bongos/core — installed platform dependency (not your project's CLAUDE.md)
|
|
2
|
+
|
|
3
|
+
This file is a placeholder shipped inside the **@bongos/core** package. The core's own
|
|
4
|
+
internal agent notes are intentionally omitted from the published artifact so they cannot
|
|
5
|
+
load into your instance's context.
|
|
6
|
+
|
|
7
|
+
**It does not govern this repository.** Your own root `CLAUDE.md`, your `.claude/`
|
|
8
|
+
directory, and the live Bongos database are the source of truth for how work happens here
|
|
9
|
+
and who may do what.
|
|
10
|
+
|
|
11
|
+
You are seeing this only because an agent opened a file under `node_modules/@bongos/core/`.
|
|
12
|
+
Nothing in here needs to be read to use the platform — run `bongos help` for commands.
|
|
13
|
+
|
|
14
|
+
_To work ON the platform core itself, use the standalone @bongos/core source repository,
|
|
15
|
+
not this vendored copy._
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// modules/copy-desk/flags.js — the copy_desk_flags domain (task 1003113 / R02 of goal
|
|
2
|
+
// 1000074, the artist's loop).
|
|
3
|
+
//
|
|
4
|
+
// The SOURCING half of the loop: anyone in any role marks a string — or a whole
|
|
5
|
+
// surface — as needing an artist's attention, with a reason. The artist reads
|
|
6
|
+
// the queue built from those rows (registry.js composes it).
|
|
7
|
+
//
|
|
8
|
+
// THE REASON FLOOR IS ENFORCED TWICE, ON PURPOSE. `normalizeFlagPayload` below
|
|
9
|
+
// rejects a thin reason with a named 400, and copy_desk_001_flags.sql rejects it
|
|
10
|
+
// again with a CHECK constraint. That is not belt-and-braces for its own sake:
|
|
11
|
+
// the route layer gives a person a usable error, and the constraint means no
|
|
12
|
+
// future write path — a backfill, a CLI, a later module — can quietly create the
|
|
13
|
+
// reasonless flag this queue exists to not contain.
|
|
14
|
+
//
|
|
15
|
+
// NO LIVE CMS (the goal's hard non-goal). Nothing in this file writes a
|
|
16
|
+
// user-facing string, and no function here returns one to a renderer as
|
|
17
|
+
// authoritative copy. `text_at_flag` is a historical snapshot for a human
|
|
18
|
+
// reading the flag later; the live text always comes from the registry.
|
|
19
|
+
// See modules/copy-desk/tests/copy_no_cms.mjs, which pins that as a test.
|
|
20
|
+
//
|
|
21
|
+
// The PURE seam (normalizeFlagPayload / normalizeResolution) is exported for the
|
|
22
|
+
// DB-free unit gate; the SQL below it is deliberately thin.
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const { pool } = require('../../src/module-api');
|
|
27
|
+
|
|
28
|
+
// Mirrors of the DB CHECK constraints — enforced here first so a bad payload
|
|
29
|
+
// gets a clean, named 400 instead of a raw constraint violation surfacing as a
|
|
30
|
+
// 500. The DB stays the backstop; this is the error message.
|
|
31
|
+
const SCOPES = Object.freeze(['string', 'surface']);
|
|
32
|
+
const STATUSES = Object.freeze(['open', 'resolved', 'dismissed']);
|
|
33
|
+
const KINDS = Object.freeze(['slop', 'off-voice', 'stale', 'unclear', 'duplicate', 'typo', 'other']);
|
|
34
|
+
// The closing verdicts a person can apply to an open flag. 'open' is not among
|
|
35
|
+
// them — a flag is opened by filing it, not by resolving it to open.
|
|
36
|
+
const CLOSING_STATUSES = Object.freeze(['resolved', 'dismissed']);
|
|
37
|
+
|
|
38
|
+
// The floor, matching copy_desk_flags_reason_chk. Not a quality bar — a quality bar
|
|
39
|
+
// cannot be a number. This rejects "", "bad", "fix" and nothing more.
|
|
40
|
+
const MIN_REASON_CHARS = 10;
|
|
41
|
+
const MAX_REASON_CHARS = 2000;
|
|
42
|
+
const MAX_NOTE_CHARS = 2000;
|
|
43
|
+
|
|
44
|
+
const fail = (code, detail) => ({ ok: false, code, detail: detail ?? null });
|
|
45
|
+
|
|
46
|
+
// Collapse runs of whitespace and trim, so the length floor measures CONTENT and
|
|
47
|
+
// not spacebar. " f i x " is 3 characters of reason, not 15.
|
|
48
|
+
function tidy(raw) {
|
|
49
|
+
return String(raw == null ? '' : raw).replace(/\s+/g, ' ').trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Validate + normalize one POSTed flag into the exact column shape insertFlag
|
|
53
|
+
// writes. PURE — no DB, no fs — so the whole contract is unit-testable.
|
|
54
|
+
//
|
|
55
|
+
// `registryIndex` is the loaded registry's index (registry.js). It is REQUIRED:
|
|
56
|
+
// a flag that points at a surface or string the registry does not contain is a
|
|
57
|
+
// dangling pointer, and the artist inherits it. Validating against the live
|
|
58
|
+
// registry rather than a hardcoded list means the generator stays the single
|
|
59
|
+
// place a surface is defined.
|
|
60
|
+
//
|
|
61
|
+
// The SNAPSHOT is taken from the registry here, never from the body — a
|
|
62
|
+
// caller-supplied "this is what it currently says" is a caller-supplied claim,
|
|
63
|
+
// and the snapshot's entire value is that it can be trusted later.
|
|
64
|
+
function normalizeFlagPayload(body, registryIndex, registryHelpers) {
|
|
65
|
+
const b = body || {};
|
|
66
|
+
|
|
67
|
+
const scope = String(b.scope || 'string');
|
|
68
|
+
if (!SCOPES.includes(scope)) return fail('bad_scope', { allowed: SCOPES });
|
|
69
|
+
|
|
70
|
+
const surface = String(b.surface || '');
|
|
71
|
+
if (!surface) return fail('surface_required');
|
|
72
|
+
if (!registryHelpers.hasSurface(registryIndex, surface)) {
|
|
73
|
+
return fail('unknown_surface', { surface, known: [...registryIndex.surfaces] });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const kind = String(b.kind || 'other');
|
|
77
|
+
if (!KINDS.includes(kind)) return fail('bad_kind', { allowed: KINDS });
|
|
78
|
+
|
|
79
|
+
const reason = tidy(b.reason);
|
|
80
|
+
if (reason.length < MIN_REASON_CHARS) {
|
|
81
|
+
// The message says WHY, not just that a rule fired — this refusal is read by
|
|
82
|
+
// someone who is trying to help, and "reason too short" without the reason
|
|
83
|
+
// for the reason reads as bureaucracy.
|
|
84
|
+
return fail('reason_too_thin', {
|
|
85
|
+
min_chars: MIN_REASON_CHARS,
|
|
86
|
+
got: reason.length,
|
|
87
|
+
why: 'A flag hands an artist a pointer and a judgement. Without the judgement it costs them a look and tells them nothing — which is worse than not flagging it.',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (reason.length > MAX_REASON_CHARS) return fail('reason_too_long', { max_chars: MAX_REASON_CHARS });
|
|
91
|
+
|
|
92
|
+
let stringId = null;
|
|
93
|
+
let snapshot = null;
|
|
94
|
+
if (scope === 'string') {
|
|
95
|
+
stringId = String(b.string_id || '');
|
|
96
|
+
if (!stringId) return fail('string_id_required', { why: "scope 'string' names one registry row; use scope 'surface' to flag a whole surface." });
|
|
97
|
+
snapshot = registryHelpers.snapshotFor(registryIndex, stringId);
|
|
98
|
+
if (!snapshot) return fail('unknown_string_id', { string_id: stringId, at: registryHelpers.REGISTRY_REL });
|
|
99
|
+
// The id encodes its surface, so a body claiming a different one is either a
|
|
100
|
+
// stale client or a confused one. Trust the registry, refuse the mismatch —
|
|
101
|
+
// silently overwriting it would file the flag onto a surface the reporter
|
|
102
|
+
// did not choose, and they would never see where it went.
|
|
103
|
+
if (snapshot.surface && snapshot.surface !== surface) {
|
|
104
|
+
return fail('surface_mismatch', { string_id: stringId, body_surface: surface, registry_surface: snapshot.surface });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
value: {
|
|
111
|
+
scope,
|
|
112
|
+
surface,
|
|
113
|
+
string_id: stringId,
|
|
114
|
+
reason,
|
|
115
|
+
kind,
|
|
116
|
+
text_at_flag: snapshot ? snapshot.text : null,
|
|
117
|
+
file_at_flag: snapshot ? snapshot.file : null,
|
|
118
|
+
line_at_flag: snapshot ? snapshot.line : null,
|
|
119
|
+
registry_schema: snapshot ? snapshot.schema : (registryIndex.schema ?? null),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Validate a close (resolve / dismiss). PURE.
|
|
125
|
+
//
|
|
126
|
+
// A NOTE IS REQUIRED TO CLOSE, for the same reason one is required to open. The
|
|
127
|
+
// symmetry is the point: "someone looked at this and decided" is the record the
|
|
128
|
+
// NEXT person needs when they hit the same sentence, and an unexplained close is
|
|
129
|
+
// indistinguishable from a queue being cleared to make a number go down.
|
|
130
|
+
function normalizeResolution(body) {
|
|
131
|
+
const b = body || {};
|
|
132
|
+
const status = String(b.status || '');
|
|
133
|
+
if (!CLOSING_STATUSES.includes(status)) return fail('bad_status', { allowed: CLOSING_STATUSES });
|
|
134
|
+
|
|
135
|
+
const note = tidy(b.resolution_note);
|
|
136
|
+
if (note.length < MIN_REASON_CHARS) {
|
|
137
|
+
return fail('resolution_note_too_thin', {
|
|
138
|
+
min_chars: MIN_REASON_CHARS,
|
|
139
|
+
got: note.length,
|
|
140
|
+
why: 'Closing a flag is a judgement too. The next person to notice this sentence reads your note instead of re-litigating it.',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (note.length > MAX_NOTE_CHARS) return fail('resolution_note_too_long', { max_chars: MAX_NOTE_CHARS });
|
|
144
|
+
|
|
145
|
+
let taskId = null;
|
|
146
|
+
if (b.resolved_task_id !== undefined && b.resolved_task_id !== null && b.resolved_task_id !== '') {
|
|
147
|
+
taskId = Number(b.resolved_task_id);
|
|
148
|
+
if (!Number.isInteger(taskId) || taskId <= 0) return fail('bad_resolved_task_id');
|
|
149
|
+
}
|
|
150
|
+
// A 'resolved' close SHOULD name the task that carried the fix — that link is
|
|
151
|
+
// the ledger proof R03 hangs its proposals on. It is not REQUIRED, because a
|
|
152
|
+
// string can legitimately be fixed by a task filed before this flag existed,
|
|
153
|
+
// and refusing the close would leave a false open flag standing. Absence is
|
|
154
|
+
// recorded (the column stays NULL) rather than being papered over.
|
|
155
|
+
return { ok: true, value: { status, resolution_note: note, resolved_task_id: taskId } };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// SQL. Thin by design — the rules live above and in the migration's constraints.
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
// One list of columns, rendered two ways: qualified for the joined reads, bare
|
|
163
|
+
// for RETURNING (which has no alias in scope). Derived from the single array so
|
|
164
|
+
// the two can never drift — the reads and the write always project the same row
|
|
165
|
+
// shape, which is what lets the hall render an insert response and a queue row
|
|
166
|
+
// with one function.
|
|
167
|
+
const FLAG_COL_NAMES = Object.freeze([
|
|
168
|
+
'id', 'scope', 'surface', 'string_id', 'reason', 'kind',
|
|
169
|
+
'text_at_flag', 'file_at_flag', 'line_at_flag', 'registry_schema',
|
|
170
|
+
'flagged_by', 'flagged_at', 'status', 'resolution_note',
|
|
171
|
+
'resolved_task_id', 'resolved_by', 'resolved_at',
|
|
172
|
+
]);
|
|
173
|
+
const FLAG_COLS = FLAG_COL_NAMES.map((c) => `f.${c}`).join(', ');
|
|
174
|
+
const FLAG_COLS_BARE = FLAG_COL_NAMES.join(', ');
|
|
175
|
+
|
|
176
|
+
async function insertFlag(value, builderId) {
|
|
177
|
+
const { rows } = await pool.query(
|
|
178
|
+
`INSERT INTO copy_desk_flags
|
|
179
|
+
(scope, surface, string_id, reason, kind, text_at_flag, file_at_flag, line_at_flag, registry_schema, flagged_by)
|
|
180
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
|
181
|
+
RETURNING ${FLAG_COLS_BARE}`,
|
|
182
|
+
[value.scope, value.surface, value.string_id, value.reason, value.kind,
|
|
183
|
+
value.text_at_flag, value.file_at_flag, value.line_at_flag, value.registry_schema, builderId]
|
|
184
|
+
);
|
|
185
|
+
return rows[0];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The queue read. `status` defaults to 'open' — the queue is what is OUTSTANDING;
|
|
189
|
+
// history is available by asking for it, never mixed into the working list by
|
|
190
|
+
// default.
|
|
191
|
+
//
|
|
192
|
+
// Joined to builders for the display name: a flag is a judgement by a PERSON,
|
|
193
|
+
// and an artist deciding how much weight to give it needs to know whose it is.
|
|
194
|
+
async function listFlags({ status = 'open', surface = null, stringId = null, limit = 200 } = {}) {
|
|
195
|
+
const where = [];
|
|
196
|
+
const args = [];
|
|
197
|
+
if (status && status !== 'all') { args.push(status); where.push(`f.status = $${args.length}`); }
|
|
198
|
+
if (surface) { args.push(surface); where.push(`f.surface = $${args.length}`); }
|
|
199
|
+
if (stringId) { args.push(stringId); where.push(`f.string_id = $${args.length}`); }
|
|
200
|
+
// Same clamp rule as registry.searchEntries: nonsense in, default out — never
|
|
201
|
+
// a single row, which would read as an almost-empty queue.
|
|
202
|
+
const asked = Number(limit);
|
|
203
|
+
args.push(Math.min(Number.isFinite(asked) && asked > 0 ? Math.floor(asked) : 200, 500));
|
|
204
|
+
|
|
205
|
+
const { rows } = await pool.query(
|
|
206
|
+
`SELECT ${FLAG_COLS},
|
|
207
|
+
b.github_login AS flagged_by_login,
|
|
208
|
+
b.display_name AS flagged_by_name,
|
|
209
|
+
r.github_login AS resolved_by_login
|
|
210
|
+
FROM copy_desk_flags f
|
|
211
|
+
JOIN builders b ON b.id = f.flagged_by
|
|
212
|
+
LEFT JOIN builders r ON r.id = f.resolved_by
|
|
213
|
+
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
214
|
+
ORDER BY f.status = 'open' DESC, f.flagged_at DESC
|
|
215
|
+
LIMIT $${args.length}`,
|
|
216
|
+
args
|
|
217
|
+
);
|
|
218
|
+
return rows;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function getFlag(id) {
|
|
222
|
+
const { rows } = await pool.query(`SELECT ${FLAG_COLS} FROM copy_desk_flags f WHERE f.id = $1`, [id]);
|
|
223
|
+
return rows[0] || null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Close a flag. Guarded on `status = 'open'` IN THE UPDATE so two people closing
|
|
227
|
+
// the same flag at once cannot both win — the second gets zero rows and a named
|
|
228
|
+
// 409, rather than silently overwriting the first person's verdict and note.
|
|
229
|
+
async function closeFlag(id, value, builderId) {
|
|
230
|
+
const { rows } = await pool.query(
|
|
231
|
+
`UPDATE copy_desk_flags
|
|
232
|
+
SET status = $2, resolution_note = $3, resolved_task_id = $4,
|
|
233
|
+
resolved_by = $5, resolved_at = now()
|
|
234
|
+
WHERE id = $1 AND status = 'open'
|
|
235
|
+
RETURNING ${FLAG_COLS_BARE}`,
|
|
236
|
+
[id, value.status, value.resolution_note, value.resolved_task_id, builderId]
|
|
237
|
+
);
|
|
238
|
+
return rows[0] || null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = {
|
|
242
|
+
SCOPES, STATUSES, KINDS, CLOSING_STATUSES, MIN_REASON_CHARS, MAX_REASON_CHARS, MAX_NOTE_CHARS,
|
|
243
|
+
tidy, normalizeFlagPayload, normalizeResolution,
|
|
244
|
+
insertFlag, listFlags, getFlag, closeFlag,
|
|
245
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
-- copy_desk_001_flags.sql — the COPY FLAG: a first-class request that an artist look
|
|
2
|
+
-- at one string, or at a whole surface (task 1003113 / R02 of goal 1000074).
|
|
3
|
+
--
|
|
4
|
+
-- WHAT A FLAG IS. R01 built the inventory (docs/copy-registry.json): every
|
|
5
|
+
-- user-facing string, with a stable id and the render path that shows it. An
|
|
6
|
+
-- inventory answers "what does the product say". It cannot answer "what is
|
|
7
|
+
-- WRONG with what the product says" — that is a judgement someone has to make
|
|
8
|
+
-- and record. This table is that record, and it is the sourcing half of the
|
|
9
|
+
-- artist's loop: anyone in any role files a flag, and the artist reads a queue
|
|
10
|
+
-- built from them.
|
|
11
|
+
--
|
|
12
|
+
-- THE REASON IS REQUIRED, IN THE SCHEMA. A flag with no reason is worthless —
|
|
13
|
+
-- it hands the artist a pointer and no judgement, which is strictly worse than
|
|
14
|
+
-- not flagging, because it still costs them a look. The board's objection flow
|
|
15
|
+
-- makes the same call for the same reason (ADR 0175), so the floor lives in a
|
|
16
|
+
-- CHECK constraint here and not only in the route: a flag with an empty or
|
|
17
|
+
-- one-word reason must be impossible to store, not merely hard to POST.
|
|
18
|
+
--
|
|
19
|
+
-- WHAT A FLAG POINTS AT, AND WHY THE SNAPSHOT COLUMNS EXIST. `string_id` is the
|
|
20
|
+
-- registry's stable id — a content hash over (surface, text, render path), so it
|
|
21
|
+
-- is stable across a file MOVE but NOT across an edit to the text itself. That is
|
|
22
|
+
-- the right behaviour (editing the copy is what resolves the flag) but it means a
|
|
23
|
+
-- flag can outlive the string it names. So every flag also carries a SNAPSHOT of
|
|
24
|
+
-- what was flagged: the text, the file and the line as they read at flag time.
|
|
25
|
+
-- Without that, a resolved flag is unreadable a month later ("what did this say
|
|
26
|
+
-- before?"), and an open flag whose string has since changed looks like a
|
|
27
|
+
-- pointer into nothing rather than what it is — a judgement about a specific
|
|
28
|
+
-- sentence that someone has since edited. The queue reads the snapshot beside
|
|
29
|
+
-- the live registry and reports when they diverge.
|
|
30
|
+
--
|
|
31
|
+
-- NO LIVE CMS — the goal's hard non-goal, and this schema is a load-bearing part
|
|
32
|
+
-- of holding it. There is deliberately NO column here that a renderer would ever
|
|
33
|
+
-- read. A flag carries a judgement and a pointer; it can never carry replacement
|
|
34
|
+
-- text, because a "proposed new string" column is one join away from being a
|
|
35
|
+
-- production copy source, and the ledger rule (every change is a claimed, graded,
|
|
36
|
+
-- shipped task) would be dead. R03 attaches proposals to TASKS; `resolved_task_id`
|
|
37
|
+
-- below is the seam it lands on. Do not add a `new_text` column here.
|
|
38
|
+
--
|
|
39
|
+
-- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
|
|
40
|
+
-- object is copy_desk_-prefixed, there is no down path, and the filename is
|
|
41
|
+
-- copy_desk_-prefixed (ADR 0108 §5) so its stem never collides across repos.
|
|
42
|
+
-- Idempotent: safe to re-run.
|
|
43
|
+
|
|
44
|
+
BEGIN;
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS copy_desk_flags (
|
|
47
|
+
id bigserial PRIMARY KEY,
|
|
48
|
+
|
|
49
|
+
-- WHAT is flagged. 'string' names one registry row; 'surface' flags a whole
|
|
50
|
+
-- surface ("the landing page reads like a brochure") — a judgement that has no
|
|
51
|
+
-- single string to hang on, and would otherwise be filed as a misleading flag
|
|
52
|
+
-- against whichever line the reporter happened to be looking at.
|
|
53
|
+
scope text NOT NULL,
|
|
54
|
+
-- The registry surface id ('builders-hall', 'status-dashboard', 'landing').
|
|
55
|
+
-- Text, not an FK: surfaces live in the generated registry, not in a table, and
|
|
56
|
+
-- a scope-wall-crossing lookup table would put the file and the DB in a race.
|
|
57
|
+
-- The route validates it against the live registry before insert.
|
|
58
|
+
surface text NOT NULL,
|
|
59
|
+
-- The registry's stable string id. NULL exactly when scope = 'surface'.
|
|
60
|
+
string_id text,
|
|
61
|
+
|
|
62
|
+
-- The judgement. REQUIRED — see the header.
|
|
63
|
+
reason text NOT NULL,
|
|
64
|
+
-- What KIND of wrong, so the artist can sort a long queue by the work it
|
|
65
|
+
-- implies (a typo and an off-voice paragraph are different sittings). Text +
|
|
66
|
+
-- CHECK rather than an enum TYPE: widening is a one-line constraint change in
|
|
67
|
+
-- a later copy_* migration.
|
|
68
|
+
kind text NOT NULL DEFAULT 'other',
|
|
69
|
+
|
|
70
|
+
-- The snapshot: what this flag was ABOUT, frozen at flag time. See the header.
|
|
71
|
+
text_at_flag text,
|
|
72
|
+
file_at_flag text,
|
|
73
|
+
line_at_flag integer,
|
|
74
|
+
-- Which registry schema produced the snapshot, so a future schema change can
|
|
75
|
+
-- tell old rows from new ones instead of guessing.
|
|
76
|
+
registry_schema integer,
|
|
77
|
+
|
|
78
|
+
flagged_by bigint NOT NULL REFERENCES builders(id),
|
|
79
|
+
flagged_at timestamptz NOT NULL DEFAULT now(),
|
|
80
|
+
|
|
81
|
+
-- open → the queue. resolved → the copy was changed. dismissed → looked at,
|
|
82
|
+
-- judged fine. Dismissal is a FIRST-CLASS outcome, not a soft delete: "an
|
|
83
|
+
-- artist read this and disagreed" is the answer to the next person who flags
|
|
84
|
+
-- the same sentence, and deleting the row throws that away.
|
|
85
|
+
status text NOT NULL DEFAULT 'open',
|
|
86
|
+
resolution_note text,
|
|
87
|
+
-- The task that carried the fix. The seam R03 lands its proposals on, and the
|
|
88
|
+
-- proof that a copy change went through the ledger rather than around it.
|
|
89
|
+
resolved_task_id bigint REFERENCES tasks(id),
|
|
90
|
+
resolved_by bigint REFERENCES builders(id),
|
|
91
|
+
resolved_at timestamptz,
|
|
92
|
+
|
|
93
|
+
CONSTRAINT copy_desk_flags_scope_chk CHECK (scope IN ('string', 'surface')),
|
|
94
|
+
CONSTRAINT copy_desk_flags_status_chk CHECK (status IN ('open', 'resolved', 'dismissed')),
|
|
95
|
+
CONSTRAINT copy_desk_flags_kind_chk CHECK (kind IN ('slop', 'off-voice', 'stale', 'unclear', 'duplicate', 'typo', 'other')),
|
|
96
|
+
-- The required-reason floor, in the schema (see the header). 10 characters of
|
|
97
|
+
-- non-whitespace is not a quality bar — it is the "" / "bad" / "fix" floor.
|
|
98
|
+
CONSTRAINT copy_desk_flags_reason_chk CHECK (length(btrim(reason)) >= 10),
|
|
99
|
+
-- string_id is present exactly when scope = 'string'. Both halves matter: a
|
|
100
|
+
-- string-scoped flag with no id points nowhere, and a surface-scoped flag
|
|
101
|
+
-- WITH one would be silently narrower than it claims to be.
|
|
102
|
+
CONSTRAINT copy_desk_flags_target_chk CHECK ((scope = 'string') = (string_id IS NOT NULL)),
|
|
103
|
+
-- A closed flag is stamped and an open one is not — in both directions, so
|
|
104
|
+
-- "resolved" can never mean "resolved by nobody at no time".
|
|
105
|
+
CONSTRAINT copy_desk_flags_closed_chk CHECK ((status = 'open') = (resolved_at IS NULL AND resolved_by IS NULL))
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
-- The queue read: open flags, newest first, optionally per surface.
|
|
109
|
+
CREATE INDEX IF NOT EXISTS copy_desk_flags_status_surface_idx
|
|
110
|
+
ON copy_desk_flags (status, surface, flagged_at DESC);
|
|
111
|
+
|
|
112
|
+
-- "Is this string already flagged?" — asked once per rendered row when the hall
|
|
113
|
+
-- marks up the registry, so it is worth an index of its own.
|
|
114
|
+
CREATE INDEX IF NOT EXISTS copy_desk_flags_string_open_idx
|
|
115
|
+
ON copy_desk_flags (string_id) WHERE status = 'open';
|
|
116
|
+
|
|
117
|
+
-- One OPEN flag per (target, builder). Two builders flagging the same sentence
|
|
118
|
+
-- is SIGNAL and stays allowed — it is the cheapest agreement measure the queue
|
|
119
|
+
-- has. The same builder filing twice is a double-click or a forgotten flag, and
|
|
120
|
+
-- the artist should not pay for either. Closed rows are exempt, so a sentence
|
|
121
|
+
-- that regresses can be flagged again by the same person.
|
|
122
|
+
CREATE UNIQUE INDEX IF NOT EXISTS copy_desk_flags_one_open_per_target_idx
|
|
123
|
+
ON copy_desk_flags (flagged_by, surface, COALESCE(string_id, '')) WHERE status = 'open';
|
|
124
|
+
|
|
125
|
+
INSERT INTO schema_migrations (version) VALUES ('copy_desk_001_flags')
|
|
126
|
+
ON CONFLICT (version) DO NOTHING;
|
|
127
|
+
|
|
128
|
+
COMMIT;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "copy-desk",
|
|
3
|
+
"title": "Copy desk (the artist's loop over user-facing text)",
|
|
4
|
+
"description": "The COPY half of the design contract (goal 1000074, the artist's loop). Named for the editorial desk copy passes over before it is published — which is exactly the job, and which keeps the module key off the generic word 'copy' that the branding contract already owns (branding.copy.tagline/hallName/notFound). ADR 0081 made the repo the source of truth for design TOKENS and component surfaces; there was no equivalent for user-facing TEXT. This module owns that half: the FLAG — anyone, in any role, marks a string or a whole surface as needing an artist's attention, with a required reason — and the QUEUE an artist reads it from. The inventory a flag points at is the committed registry docs/copy-registry.json, generated by scripts/gds/copy-inventory.js (R01); this module reads it and never writes it. NO LIVE CMS is the goal's hard non-goal: nothing here can change a rendered string. A flag is a work request; the fix ships as a claimed, graded GDS task like every other change. default-on — every instance shows a person words, so every instance can flag them.",
|
|
5
|
+
"coreVersion": "^1.8.0",
|
|
6
|
+
"default": true,
|
|
7
|
+
"maintenance": { "status": "core-maintained" },
|
|
8
|
+
"contributes": { "routes": ["copy-desk"], "migrations": true },
|
|
9
|
+
"provides": [],
|
|
10
|
+
"consumes": []
|
|
11
|
+
}
|