@bongos/core 1.19.1058 → 1.19.1060

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.
@@ -10,6 +10,11 @@
10
10
  // POST /copy-desk/proposals — propose new wording. Creates a TASK carrying
11
11
  // the patch; writes no file and stores no copy.
12
12
  //
13
+ // Tweak Mode (ADR 0341) adds the page reads (TW05) and two page writes (TW06,
14
+ // task 1004317): POST /copy-desk/pages/:pageId/claim opens or takes a page's
15
+ // tweak round as a TASK with a web claim, and POST /copy-desk/pages/:pageId/asks
16
+ // files a page ask, a flag row whose target is a page id. Neither stores copy.
17
+ //
13
18
  // THE RANK SHAPE, AND WHY IT IS ASYMMETRIC:
14
19
  //
15
20
  // FILING is open to every rank, deliberately. "Sourced by everyone else" is the
@@ -31,7 +36,7 @@
31
36
  // produce a TASK carrying a patch, never a write. A flag is a work request; a
32
37
  // proposal is a work request with a suggested fix attached. Neither reaches a
33
38
  // reader. modules/copy-desk/tests/copy_no_cms.mjs pins that by asserting this
34
- // router's write surface stays exactly these three verbs, that none of them
39
+ // router's write surface stays exactly the listed verbs, that none of them
35
40
  // touches the filesystem, and that the one place a proposed wording is allowed
36
41
  // to rest is the GDS ledger.
37
42
  //
@@ -66,7 +71,7 @@ const log = api.logger('copy-desk');
66
71
  // lookups, the scope/string_id coupling — live in flags.normalize*, so the DB
67
72
  // constraint, the pure validator and this schema each say one thing once.
68
73
  const FLAG_BODY_SCHEMA = Object.freeze({
69
- scope: { type: 'string', enum: [...flags.SCOPES] },
74
+ scope: { type: 'string', enum: [...flags.FLAG_SCOPES] },
70
75
  surface: { required: true, type: 'string', maxLength: 128, minLength: 1 },
71
76
  string_id: { type: 'string', maxLength: 64 },
72
77
  reason: { required: true, type: 'string', maxLength: flags.MAX_REASON_CHARS, minLength: 1 },
@@ -90,6 +95,14 @@ const PROPOSAL_BODY_SCHEMA = Object.freeze({
90
95
  line: { type: 'integer', min: 1 },
91
96
  flag_id: { type: 'integer', min: 1 },
92
97
  });
98
+ // The page claim (task 1004317 / TW06) takes nothing: who claims is the
99
+ // session and what is claimed is the path. Strict, so a caller cannot smuggle
100
+ // a builder, a version or a round in.
101
+ const CLAIM_BODY_SCHEMA = Object.freeze({});
102
+ // The page ask: a reason and nothing else. The target is the path's page id.
103
+ const ASK_BODY_SCHEMA = Object.freeze({
104
+ reason: { required: true, type: 'string', maxLength: flags.MAX_REASON_CHARS, minLength: 1 },
105
+ });
93
106
 
94
107
  // Load the registry or answer the named "no registry" state. Returns null when
95
108
  // it has already responded, so every handler is `if (!reg) return;`.
@@ -140,6 +153,51 @@ function readingsState() {
140
153
  : { data: null, info: { ok: false, code: loaded.code, run: loaded.detail.run } };
141
154
  }
142
155
 
156
+ // Resolve :pageId against the inventory, or answer `unknown_page` (ADR 0341
157
+ // D1). Returns null when it has already responded.
158
+ function pageOrFail(req, res) {
159
+ const pageId = String(req.params.pageId || '');
160
+ if (!pages.PAGE_ID_RE.test(pageId)) {
161
+ res.fail('unknown_page', 404, { page_id: pageId, what_this_means: 'A page id is <surface>:<page>, for example landing:index.' });
162
+ return null;
163
+ }
164
+ const inv = inventoryOrFail(res);
165
+ if (!inv) return null;
166
+ const page = (inv.data.pages || []).find((p) => p && p.id === pageId);
167
+ if (!page) {
168
+ res.fail('unknown_page', 404, { page_id: pageId, what_this_means: 'The page inventory has no page with that id.' });
169
+ return null;
170
+ }
171
+ return { page, pageId, inv };
172
+ }
173
+
174
+ // An open page ask is RESOLVED when the page's next tweak ships (ADR 0341 D6).
175
+ // Runs on the kernel's task.shipped event, after the ship has committed, so it
176
+ // can never fail the ship: every error is logged and swallowed. The event
177
+ // payload carries no source, so the task is read back through the port.
178
+ async function resolveAsksOnShip(shipped, deps = {}) {
179
+ try {
180
+ const lifecycle = deps.lifecycle || (api.resolveOptional && api.resolveOptional('lifecycle'));
181
+ const flagsLib = deps.flags || flags;
182
+ if (!shipped || shipped.id == null || !lifecycle || typeof lifecycle.getTask !== 'function') return [];
183
+ const task = await lifecycle.getTask(shipped.id);
184
+ if (!task || task.source !== pages.SOURCE || task.status !== 'shipped') return [];
185
+ const ref = pages.parseRoundRef(task.source_ref);
186
+ if (!ref) return [];
187
+ const resolvedBy = task.shipped_by != null ? task.shipped_by : shipped.shipped_by;
188
+ // copy_desk_flags_closed_chk: a closed flag names who closed it. With no
189
+ // shipper on record the asks stay open rather than be closed by nobody.
190
+ if (resolvedBy == null) return [];
191
+ return await flagsLib.resolvePageAsks({
192
+ pageId: ref.page_id, taskId: task.id, resolvedBy, shippedAt: task.shipped_at || new Date().toISOString(),
193
+ });
194
+ } catch (err) {
195
+ log.warn({ err, task_id: shipped && shipped.id }, 'resolving page asks on ship failed (non-blocking)');
196
+ return [];
197
+ }
198
+ }
199
+ let _askListenerOff = null;
200
+
143
201
  // The ledger reads come through the lifecycle port. copy-desk consumes
144
202
  // lifecycle, so the port is always there on a booted instance; the check is for
145
203
  // a core whose lifecycle predates these reads.
@@ -173,6 +231,11 @@ async function tallyCreditRows(builderId) {
173
231
  module.exports = function buildCopyDeskRouter() {
174
232
  const router = express.Router();
175
233
 
234
+ // Page asks resolve when the page's tweak ships (ADR 0341 D6). Re-registered
235
+ // on every router build (tests rebuild it), never stacked.
236
+ if (_askListenerOff) _askListenerOff();
237
+ _askListenerOff = api.on('task.shipped', (task) => resolveAsksOnShip(task));
238
+
176
239
  // -------------------------------------------------------------------------
177
240
  // GET /copy-desk/queue — the artist's working list.
178
241
  //
@@ -203,8 +266,11 @@ module.exports = function buildCopyDeskRouter() {
203
266
  // re-surfaces every already-dismissed duplicate as untouched work.
204
267
  // composeQueue owns that rule; this just feeds it both sets. When the
205
268
  // caller asked for everything the two are the same query, so it runs once.
206
- const shown = await flags.listFlags({ status, surface });
207
- const allFlags = status === 'all' ? shown : await flags.listFlags({ status: 'all', surface });
269
+ // Page asks (scope 'page', task 1004317) are the recommendation's signal,
270
+ // not rows in this string queue, so the queue reads the two flag scopes.
271
+ const scopes = flags.FLAG_SCOPES;
272
+ const shown = await flags.listFlags({ status, surface, scopes });
273
+ const allFlags = status === 'all' ? shown : await flags.listFlags({ status: 'all', surface, scopes });
208
274
 
209
275
  const queue = registry.composeQueue({
210
276
  registry: loaded.registry,
@@ -548,16 +614,9 @@ module.exports = function buildCopyDeskRouter() {
548
614
  // -------------------------------------------------------------------------
549
615
  router.get('/copy-desk/pages/:pageId', api.requireBuilder, async (req, res) => {
550
616
  try {
551
- const pageId = String(req.params.pageId || '');
552
- if (!pages.PAGE_ID_RE.test(pageId)) {
553
- return res.fail('unknown_page', 404, { page_id: pageId, what_this_means: 'A page id is <surface>:<page>, for example landing:index.' });
554
- }
555
- const inv = inventoryOrFail(res);
556
- if (!inv) return;
557
- const page = (inv.data.pages || []).find((p) => p && p.id === pageId);
558
- if (!page) {
559
- return res.fail('unknown_page', 404, { page_id: pageId, what_this_means: 'The page inventory has no page with that id.' });
560
- }
617
+ const found = pageOrFail(req, res);
618
+ if (!found) return;
619
+ const { page, pageId } = found;
561
620
  const lifecycle = pageReadsOrFail(res);
562
621
  if (!lifecycle) return;
563
622
  const readings = readingsState();
@@ -604,5 +663,185 @@ module.exports = function buildCopyDeskRouter() {
604
663
  }
605
664
  });
606
665
 
666
+ // -------------------------------------------------------------------------
667
+ // GET /copy-desk/next — the Tweak/CopyWrite Flow's recommendation (task
668
+ // 1004318 / BV2.TW07, ADR 0341 D8): the page the caller should tweak next
669
+ // and why ("2 people asked for it"), plus the next few for "pick another".
670
+ //
671
+ // Order: asked for (most distinct askers, then the oldest ask), then changed
672
+ // since the last tweak (most changed lines), then untweaked (landing,
673
+ // builders, status, then inventory order); tweaked pages with no drift last.
674
+ // A page another builder holds, or whose round is in flight, is never
675
+ // recommended (it is listed in `skipped`). The caller's own held page is
676
+ // `resume`, not a recommendation.
677
+ //
678
+ // "Take next" is POST /copy-desk/pages/:pageId/claim on `recommended.page_id`
679
+ // (each candidate carries its `take`), one call. The availability rule here
680
+ // is the claim's own, so that call is not refused for a reason this read knew.
681
+ //
682
+ // rank: any signed-in builder. The read is the caller's own and needs no
683
+ // craft; the claim it points at is where the artist gate lives.
684
+ // -------------------------------------------------------------------------
685
+ router.get('/copy-desk/next', api.requireBuilder, async (req, res) => {
686
+ try {
687
+ const inv = inventoryOrFail(res);
688
+ if (!inv) return;
689
+ const lifecycle = pageReadsOrFail(res);
690
+ if (!lifecycle) return;
691
+ const readings = readingsState();
692
+ const [tasks, asks] = await Promise.all([
693
+ lifecycle.listPageTweakTasks(),
694
+ flags.pageAskSummary(),
695
+ ]);
696
+ const out = pageStatus.composeNext({ inventory: inv.data, readings: readings.data, tasks, asks, builderId: req.builder.id });
697
+ out.inventory = { at: inv.at };
698
+ out.readings = readings.info;
699
+ res.set('Cache-Control', 'no-store');
700
+ res.json(out);
701
+ } catch (err) {
702
+ log.error({ err }, 'GET /copy-desk/next failed');
703
+ if (!res.headersSent) res.fail('copy_next_failed', 500);
704
+ }
705
+ });
706
+
707
+ // -------------------------------------------------------------------------
708
+ // POST /copy-desk/pages/:pageId/claim — claim a page's tweak (task 1004317 /
709
+ // BV2.TW06, ADR 0341 D2 and D5). Also Resume and "Take next".
710
+ //
711
+ // Body: {} (nothing — who claims is the session, what is claimed is the path).
712
+ // rank: any signed-in builder; the gate is the CRAFT, read server-side
713
+ // (builders.preferred_disciplines includes artist), never the request.
714
+ //
715
+ // One call finds the page's open round or creates it (source_ref
716
+ // page-tweak/<page_id>/r<n>, 30 credits, the building version's catch-all
717
+ // goal) and WEB-claims it: no worktree, no session. The lifecycle port runs it
718
+ // in one transaction under an advisory lock on the page, so two artists cannot
719
+ // both open a round.
720
+ //
721
+ // 201 created a new round, held by the caller
722
+ // 200 claimed an unheld round still being written (a draft released
723
+ // without submitting) taken over; the draft stays
724
+ // 200 resumed the caller already holds it
725
+ // 409 page_held another builder holds it; the holder is named
726
+ // 409 page_in_flight the round is past writing (submitted, applying,
727
+ // waiting for the artist, landing)
728
+ // 403 not_an_artist the caller's crafts do not include artist; they are
729
+ // offered the ask
730
+ // -------------------------------------------------------------------------
731
+ router.post('/copy-desk/pages/:pageId/claim', api.requireBuilder, async (req, res) => {
732
+ try {
733
+ if (validateOrRespond(req, res, CLAIM_BODY_SCHEMA)) return;
734
+ const found = pageOrFail(req, res);
735
+ if (!found) return;
736
+ const { page, pageId } = found;
737
+ const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
738
+ if (!lifecycle || typeof lifecycle.webClaimPageTweak !== 'function') {
739
+ return res.fail('lifecycle_unavailable', 503, {
740
+ what_this_means: 'A page claim opens a task in the ledger, and this instance cannot. Nothing was recorded.',
741
+ });
742
+ }
743
+ // Where a NEW round lands: the version being built, as a copy proposal
744
+ // lands (no fallback to another version; see resolveTargetVersion).
745
+ const building = proposals.resolveTargetVersion(await lifecycle.listVersions());
746
+ const r = await lifecycle.webClaimPageTweak({
747
+ pageId,
748
+ builderId: req.builder.id,
749
+ versionId: building ? building.id : null,
750
+ round: {
751
+ title: () => pages.roundTaskTitle(page),
752
+ description: (n) => pages.roundTaskBody(page, n),
753
+ sourceRef: (n) => pages.roundSourceRef(pageId, n),
754
+ touches: Array.isArray(page.files) ? page.files : [],
755
+ creditsReward: pages.ROUND_CREDITS,
756
+ canTakeOver: pages.roundOpenForWriting,
757
+ },
758
+ });
759
+ const round = r.task ? { task_id: String(r.task.id), round: r.round, source_ref: r.task.source_ref, status: r.task.status } : null;
760
+ switch (r.outcome) {
761
+ case 'created':
762
+ case 'claimed':
763
+ case 'resumed':
764
+ return res.status(r.outcome === 'created' ? 201 : 200).json({
765
+ ok: true, outcome: r.outcome, page_id: pageId, round: { ...round, state: 'writing' },
766
+ claim: r.claim ? { id: String(r.claim.id), claimed_at: r.claim.claimed_at } : null,
767
+ });
768
+ case 'held':
769
+ return res.fail('page_held', 409, {
770
+ page_id: pageId, round, holder: r.holder,
771
+ what_this_means: r.holder_is_writing
772
+ ? `${r.holder.name || r.holder.login || 'Another artist'} is writing this page now. Ask for it instead, or take another.`
773
+ : `${r.holder.name || r.holder.login || 'Another builder'} is applying this page's submitted words. It comes back when it ships or is sent back.`,
774
+ });
775
+ case 'in_flight':
776
+ return res.fail('page_in_flight', 409, {
777
+ page_id: pageId, round,
778
+ what_this_means: 'This page\'s round has been submitted and is on its way to the site. It can be claimed again once it ships or is sent back.',
779
+ });
780
+ case 'not_an_artist':
781
+ return res.fail('not_an_artist', 403, {
782
+ page_id: pageId,
783
+ what_this_means: 'Only a builder whose crafts include artist can claim a page. Anyone can ask for one.',
784
+ your_options: [`POST /copy-desk/pages/${pageId}/asks with a one-line reason`, 'Add artist to your crafts in Settings'],
785
+ });
786
+ case 'builder_inactive':
787
+ return res.fail('builder_inactive', 403);
788
+ case 'no_open_version':
789
+ return res.fail('no_open_version', 503, {
790
+ what_this_means: 'No version is currently being built, so there is nowhere for a new page round to land. Nothing was recorded.',
791
+ });
792
+ default:
793
+ throw new Error(`webClaimPageTweak: unknown outcome ${r.outcome}`);
794
+ }
795
+ } catch (err) {
796
+ log.error({ err }, 'POST /copy-desk/pages/:pageId/claim failed');
797
+ if (!res.headersSent) res.fail('copy_page_claim_failed', 500);
798
+ }
799
+ });
800
+
801
+ // -------------------------------------------------------------------------
802
+ // POST /copy-desk/pages/:pageId/asks — ask for a page (task 1004317 / TW06,
803
+ // ADR 0341 D6).
804
+ //
805
+ // Body: { reason } — one line, the flag's own floor (10 characters).
806
+ // rank: any signed-in builder, any craft. A page ask is a copy_desk_flags row
807
+ // with scope 'page'; it carries a judgement and a page id, never text.
808
+ //
809
+ // 201 a new ask · 200 the caller's open ask on this page already (the first
810
+ // one comes back; a double ask is not a second signal)
811
+ // Both carry `askers`: distinct builders with an open ask on the page.
812
+ // -------------------------------------------------------------------------
813
+ router.post('/copy-desk/pages/:pageId/asks', api.requireBuilder, async (req, res) => {
814
+ try {
815
+ if (validateOrRespond(req, res, ASK_BODY_SCHEMA)) return;
816
+ const found = pageOrFail(req, res);
817
+ if (!found) return;
818
+ const { pageId } = found;
819
+ const norm = flags.normalizePageAsk(req.body, pageId);
820
+ if (!norm.ok) return res.fail(norm.code, 400, norm.detail);
821
+
822
+ let ask = await flags.findOpenPageAsk(pageId, req.builder.id);
823
+ let created = false;
824
+ if (!ask) {
825
+ try {
826
+ ask = await flags.insertFlag(norm.value, req.builder.id);
827
+ created = true;
828
+ } catch (err) {
829
+ // 23505: a concurrent ask by the same builder won the one-open-per-
830
+ // (builder, page) index. Same answer as the read above.
831
+ if (!(err && err.code === '23505')) throw err;
832
+ ask = await flags.findOpenPageAsk(pageId, req.builder.id);
833
+ if (!ask) throw err;
834
+ }
835
+ }
836
+ const askers = await flags.countPageAskers(pageId);
837
+ res.status(created ? 201 : 200).json({ ok: true, created, ask, page_id: pageId, askers });
838
+ } catch (err) {
839
+ log.error({ err }, 'POST /copy-desk/pages/:pageId/asks failed');
840
+ if (!res.headersSent) res.fail('copy_page_ask_failed', 500);
841
+ }
842
+ });
843
+
607
844
  return router;
608
845
  };
846
+
847
+ module.exports.resolveAsksOnShip = resolveAsksOnShip;
@@ -188,15 +188,65 @@ await test('the JS enums match the migration CHECK constraints exactly', async (
188
188
  const url = await import('node:url');
189
189
  const path = await import('node:path');
190
190
  const here = path.dirname(url.fileURLToPath(import.meta.url));
191
- const sql = fs.readFileSync(path.join(here, '..', 'migrations', 'copy_desk_001_flags.sql'), 'utf8');
191
+ // Every migration, oldest first: a later one may widen a CHECK (TW06's
192
+ // copy_desk_002 widens scope), and the LAST definition is the one in force.
193
+ const dir = path.join(here, '..', 'migrations');
194
+ const sql = fs.readdirSync(dir).filter((x) => x.endsWith('.sql')).sort()
195
+ .map((x) => fs.readFileSync(path.join(dir, x), 'utf8')).join('\n');
192
196
  for (const [name, list] of [['kind', flags.KINDS], ['scope', flags.SCOPES], ['status', flags.STATUSES]]) {
193
- const m = new RegExp(`CHECK \\(${name} IN \\(([^)]*)\\)\\)`).exec(sql);
197
+ const all = [...sql.matchAll(new RegExp(`CHECK \\(${name} IN \\(([^)]*)\\)\\)`, 'g'))];
198
+ const m = all[all.length - 1];
194
199
  assert.ok(m, `migration has no CHECK for ${name}`);
195
200
  const inSql = m[1].split(',').map((s) => s.trim().replace(/'/g, ''));
196
201
  assert.deepEqual(inSql.sort(), [...list].sort(), `${name} enum drifted between flags.js and the migration`);
197
202
  }
198
203
  });
199
204
 
205
+ // ---------------------------------------------------------------------------
206
+ // Page asks (task 1004317 / TW06, ADR 0341 D6).
207
+ // ---------------------------------------------------------------------------
208
+
209
+ console.log('copy-desk: page asks');
210
+
211
+ await test('the flag route refuses scope page and points at the ask route', () => {
212
+ const r = ok({ scope: 'page', surface: 'landing', reason: GOOD_REASON });
213
+ assert.equal(r.ok, false);
214
+ assert.equal(r.code, 'bad_scope');
215
+ assert.deepEqual([...r.detail.allowed], ['string', 'surface']);
216
+ assert.ok(/asks/.test(r.detail.why));
217
+ });
218
+
219
+ await test('a page ask carries the flag reason floor', () => {
220
+ const thin = flags.normalizePageAsk({ reason: ' do it ' }, 'landing:index');
221
+ assert.equal(thin.ok, false);
222
+ assert.equal(thin.code, 'reason_too_thin');
223
+ const long = flags.normalizePageAsk({ reason: 'x'.repeat(flags.MAX_REASON_CHARS + 1) }, 'landing:index');
224
+ assert.equal(long.code, 'reason_too_long');
225
+ });
226
+
227
+ await test('a page ask is scope page, points at the page twice, and holds no text', () => {
228
+ const r = flags.normalizePageAsk({ reason: GOOD_REASON }, 'builders:studio');
229
+ assert.equal(r.ok, true);
230
+ assert.equal(r.value.scope, 'page');
231
+ assert.equal(r.value.page_id, 'builders:studio');
232
+ // surface = page id: what makes 001's one-open-per-target index mean one
233
+ // open ask per (builder, page), and what copy_desk_flags_page_surface_chk pins.
234
+ assert.equal(r.value.surface, 'builders:studio');
235
+ assert.equal(r.value.string_id, null);
236
+ assert.equal(r.value.text_at_flag, null);
237
+ });
238
+
239
+ await test('the migration pins the page target both ways and ties surface to the page', async () => {
240
+ const fs = await import('node:fs');
241
+ const url = await import('node:url');
242
+ const path = await import('node:path');
243
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
244
+ const sql = fs.readFileSync(path.join(here, '..', 'migrations', 'copy_desk_002_page_asks.sql'), 'utf8');
245
+ assert.ok(/\(scope = 'page'\) = \(page_id IS NOT NULL\)/.test(sql), 'page_id present exactly for scope page');
246
+ assert.ok(/\(scope = 'string'\) = \(string_id IS NOT NULL\)/.test(sql), 'the string half of the target rule survives the widening');
247
+ assert.ok(/scope <> 'page' OR surface = page_id/.test(sql), 'a page ask keeps surface = page_id');
248
+ });
249
+
200
250
  // ---------------------------------------------------------------------------
201
251
  // Closing a flag.
202
252
  // ---------------------------------------------------------------------------
@@ -67,7 +67,7 @@ const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'pa
67
67
 
68
68
  console.log('copy-desk: NO LIVE CMS');
69
69
 
70
- await test('the write surface is exactly the three verbs the module defines', () => {
70
+ await test('the write surface is exactly the verbs the module defines', () => {
71
71
  const src = stripComments(read('routes/copy-desk.js'));
72
72
  const found = [...src.matchAll(/router\.(post|put|patch|delete)\(\s*'([^']+)'/g)]
73
73
  .map((m) => `${m[1].toUpperCase()} ${m[2]}`)
@@ -75,12 +75,21 @@ await test('the write surface is exactly the three verbs the module defines', ()
75
75
  assert.deepEqual(found, [
76
76
  'POST /copy-desk/flags',
77
77
  'POST /copy-desk/flags/:id/close',
78
+ // TW06 (task 1004317, ADR 0341 D5, D6, D11): the page claim opens or takes a
79
+ // TASK and a web claim through the lifecycle port; the page ask writes one
80
+ // flag row whose target is a page id. Neither stores wording.
81
+ 'POST /copy-desk/pages/:pageId/asks',
82
+ 'POST /copy-desk/pages/:pageId/claim',
78
83
  'POST /copy-desk/proposals',
79
84
  ], 'a new write route on this module must be a deliberate, reviewed change to this list');
80
85
  });
81
86
 
87
+ // Every migration this module ships, oldest first (the list is pinned below).
88
+ const MIGRATION_FILES = ['copy_desk_001_flags.sql', 'copy_desk_002_page_asks.sql'];
89
+ const allMigrationSql = () => MIGRATION_FILES.map((f) => stripComments(read(`migrations/${f}`))).join('\n');
90
+
82
91
  await test('no column on copy_desk_flags could hold replacement text', () => {
83
- const sql = stripComments(read('migrations/copy_desk_001_flags.sql'));
92
+ const sql = allMigrationSql();
84
93
  // The names a "just store the suggested wording" column would plausibly take.
85
94
  const forbidden = /\b(new_text|proposed_text|suggested_text|replacement|replacement_text|new_copy|proposed_copy|override_text|value_text)\b/i;
86
95
  const hit = forbidden.exec(sql);
@@ -88,22 +97,26 @@ await test('no column on copy_desk_flags could hold replacement text', () => {
88
97
  `copy_desk_flags must carry a judgement and a pointer, never replacement copy (found "${hit && hit[0]}")`);
89
98
  });
90
99
 
91
- await test('R03 added no migration — a proposal is a task, not a row here', () => {
100
+ await test('the module has exactly its two migrations — a proposal and a page round are tasks, not rows here', () => {
92
101
  // The load-bearing one. R03's obvious-but-wrong implementation is a
93
102
  // `copy_desk_proposals` table, which is the forbidden column with extra steps:
94
103
  // once the wording lives in this module's schema, one plausible read of it
95
104
  // turns the module into the copy source. The proposal lives on the TASK.
96
105
  const migrations = fs.readdirSync(path.join(MODULE_DIR, 'migrations')).sort();
97
- assert.deepEqual(migrations, ['copy_desk_001_flags.sql'],
98
- 'a second migration in this module needs a hard look at whether it stores copy');
106
+ // TW06 (task 1004317) added the second, deliberately: copy_desk_002_page_asks
107
+ // widens the flag to a page scope and adds one POINTER column, page_id. It
108
+ // stores no wording; a page round, like a proposal, is a task.
109
+ assert.deepEqual(migrations, MIGRATION_FILES,
110
+ 'another migration in this module needs a hard look at whether it stores copy');
99
111
  });
100
112
 
101
113
  await test('the only text column is the historical snapshot, and it is never the live source', () => {
102
- const sql = stripComments(read('migrations/copy_desk_001_flags.sql'));
103
- const textish = [...sql.matchAll(/^\s{2}(\w+)\s+text\b/gm)].map((m) => m[1]);
104
- assert.deepEqual(textish.sort(), [
105
- 'file_at_flag', 'kind', 'reason', 'resolution_note', 'scope', 'status', 'string_id', 'surface', 'text_at_flag',
106
- ], 'a new text column here needs a deliberate look at whether it is copy');
114
+ const sql = allMigrationSql();
115
+ const created = [...sql.matchAll(/^\s{2}(\w+)\s+text\b/gm)].map((m) => m[1]);
116
+ const added = [...sql.matchAll(/ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s+text\b/gi)].map((m) => m[1]);
117
+ assert.deepEqual([...created, ...added].sort(), [
118
+ 'file_at_flag', 'kind', 'page_id', 'reason', 'resolution_note', 'scope', 'status', 'string_id', 'surface', 'text_at_flag',
119
+ ], 'a new text column here needs a deliberate look at whether it is copy (page_id, TW06, is a page pointer)');
107
120
 
108
121
  // And the live read path is the registry, not the row: annotateFlag fills
109
122
  // `current` from the registry index every time.
@@ -140,7 +153,11 @@ await test('the proposal domain holds no path to a renderer — it composes text
140
153
 
141
154
  await test('the proposal route creates a TASK and does not write a string anywhere', () => {
142
155
  const src = stripComments(read('routes/copy-desk.js'));
143
- const route = src.slice(src.indexOf("router.post('/copy-desk/proposals'"));
156
+ // Bounded to THIS handler: the slice ends where the next route begins, so a
157
+ // later route (the page ask does call insertFlag) is not read as this one.
158
+ const start = src.indexOf("router.post('/copy-desk/proposals'");
159
+ const next = src.indexOf('router.', start + 1);
160
+ const route = src.slice(start, next === -1 ? undefined : next);
144
161
  assert.ok(/lifecycle\.createTask\(/.test(route),
145
162
  'the proposal path must land in the ledger via lifecycle.createTask');
146
163
  // No INSERT/UPDATE of this module's own tables on the proposal path — the
@@ -149,6 +166,25 @@ await test('the proposal route creates a TASK and does not write a string anywhe
149
166
  'the proposal path must not write a row in this module — the task IS the record');
150
167
  });
151
168
 
169
+ await test('the page claim creates a TASK through the port and writes no row here; the ask writes a pointer', () => {
170
+ const src = stripComments(read('routes/copy-desk.js'));
171
+ const handler = (path) => {
172
+ const start = src.indexOf(`router.post('${path}'`);
173
+ assert.ok(start >= 0, `${path} is mounted`);
174
+ const next = src.indexOf('router.', start + 1);
175
+ return src.slice(start, next === -1 ? undefined : next);
176
+ };
177
+ const claim = handler('/copy-desk/pages/:pageId/claim');
178
+ assert.ok(/lifecycle\.webClaimPageTweak\(/.test(claim), 'the page claim must land in the ledger via the lifecycle port');
179
+ assert.equal(/insertFlag|closeFlag|INSERT INTO|UPDATE |description:\s*req\.body/i.test(claim), false,
180
+ 'the page claim must write no row in this module and take no wording from the request');
181
+ const ask = handler('/copy-desk/pages/:pageId/asks');
182
+ assert.ok(/normalizePageAsk\(/.test(ask) && /insertFlag\(/.test(ask), 'the ask files through the validated flag write');
183
+ // The round's description is composed by pages.js from the INVENTORY page,
184
+ // never from anything a caller sent.
185
+ assert.ok(/roundTaskBody\(page, n\)/.test(claim), 'the round body comes from pages.roundTaskBody over the inventory page');
186
+ });
187
+
152
188
  await test('the module manifest declares no seam another module could write copy through', () => {
153
189
  const manifest = JSON.parse(read('module.json'));
154
190
  assert.deepEqual(manifest.provides, [], 'a provided port here would be a copy write path for every other module');