@clossys/writer 0.4.0 → 0.5.0

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 (109) hide show
  1. package/README.md +587 -2
  2. package/dist/approval-bypass.d.ts +120 -0
  3. package/dist/approval-bypass.d.ts.map +1 -0
  4. package/dist/approval-bypass.js +2318 -0
  5. package/dist/approval-bypass.js.map +1 -0
  6. package/dist/approval.d.ts +62 -0
  7. package/dist/approval.d.ts.map +1 -0
  8. package/dist/approval.js +421 -0
  9. package/dist/approval.js.map +1 -0
  10. package/dist/approve-cli.d.ts +82 -0
  11. package/dist/approve-cli.d.ts.map +1 -0
  12. package/dist/approve-cli.js +430 -0
  13. package/dist/approve-cli.js.map +1 -0
  14. package/dist/cli.d.ts +15 -0
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +48 -13
  17. package/dist/cli.js.map +1 -1
  18. package/dist/field-options.d.ts +59 -0
  19. package/dist/field-options.d.ts.map +1 -0
  20. package/dist/field-options.js +109 -0
  21. package/dist/field-options.js.map +1 -0
  22. package/dist/front-door.check.d.ts +2 -0
  23. package/dist/front-door.check.d.ts.map +1 -0
  24. package/dist/front-door.check.js +8 -0
  25. package/dist/front-door.check.js.map +1 -0
  26. package/dist/front-door.d.ts +82 -0
  27. package/dist/front-door.d.ts.map +1 -0
  28. package/dist/front-door.js +271 -0
  29. package/dist/front-door.js.map +1 -0
  30. package/dist/generated/advisor-plan-contract.generated.d.ts +3 -0
  31. package/dist/generated/advisor-plan-contract.generated.d.ts.map +1 -0
  32. package/dist/generated/advisor-plan-contract.generated.js +430 -0
  33. package/dist/generated/advisor-plan-contract.generated.js.map +1 -0
  34. package/dist/generated/contract-schema.generated.d.ts +97 -0
  35. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  36. package/dist/generated/contract-schema.generated.js +496 -0
  37. package/dist/generated/contract-schema.generated.js.map +1 -0
  38. package/dist/index.d.ts +14 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +24 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/messaging-kit.d.ts +113 -0
  43. package/dist/messaging-kit.d.ts.map +1 -0
  44. package/dist/messaging-kit.js +121 -0
  45. package/dist/messaging-kit.js.map +1 -0
  46. package/dist/passage.d.ts +2 -1
  47. package/dist/passage.d.ts.map +1 -1
  48. package/dist/passage.js +2 -1
  49. package/dist/passage.js.map +1 -1
  50. package/dist/plan-authority.d.ts +73 -0
  51. package/dist/plan-authority.d.ts.map +1 -0
  52. package/dist/plan-authority.js +126 -0
  53. package/dist/plan-authority.js.map +1 -0
  54. package/dist/plan-contract.d.ts +16 -0
  55. package/dist/plan-contract.d.ts.map +1 -0
  56. package/dist/plan-contract.js +33 -0
  57. package/dist/plan-contract.js.map +1 -0
  58. package/dist/plan-digest.d.ts +23 -0
  59. package/dist/plan-digest.d.ts.map +1 -0
  60. package/dist/plan-digest.js +82 -0
  61. package/dist/plan-digest.js.map +1 -0
  62. package/dist/plan-rules.d.ts +54 -0
  63. package/dist/plan-rules.d.ts.map +1 -0
  64. package/dist/plan-rules.js +145 -0
  65. package/dist/plan-rules.js.map +1 -0
  66. package/dist/resolve.d.ts +40 -6
  67. package/dist/resolve.d.ts.map +1 -1
  68. package/dist/resolve.js +144 -18
  69. package/dist/resolve.js.map +1 -1
  70. package/dist/scan.d.ts +32 -0
  71. package/dist/scan.d.ts.map +1 -1
  72. package/dist/scan.js +67 -6
  73. package/dist/scan.js.map +1 -1
  74. package/dist/schema.d.ts +1 -0
  75. package/dist/schema.d.ts.map +1 -1
  76. package/dist/schema.js +209 -5
  77. package/dist/schema.js.map +1 -1
  78. package/dist/site-identity.d.ts +88 -0
  79. package/dist/site-identity.d.ts.map +1 -0
  80. package/dist/site-identity.js +82 -0
  81. package/dist/site-identity.js.map +1 -0
  82. package/dist/types.d.ts +65 -7
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js +9 -7
  85. package/dist/types.js.map +1 -1
  86. package/package.json +7 -6
  87. package/skill/SKILL.md +2 -2
  88. package/src/approval-bypass.ts +2263 -0
  89. package/src/approval.ts +491 -0
  90. package/src/approve-cli.ts +474 -0
  91. package/src/cli.ts +46 -13
  92. package/src/field-options.ts +134 -0
  93. package/src/front-door.check.ts +10 -0
  94. package/src/front-door.ts +312 -0
  95. package/src/generated/advisor-plan-contract.generated.ts +430 -0
  96. package/src/generated/contract-schema.generated.ts +520 -0
  97. package/src/index.ts +97 -1
  98. package/src/messaging-kit.ts +195 -0
  99. package/src/passage.ts +2 -1
  100. package/src/plan-authority.ts +160 -0
  101. package/src/plan-contract.ts +45 -0
  102. package/src/plan-digest.ts +81 -0
  103. package/src/plan-rules.ts +179 -0
  104. package/src/resolve.ts +185 -19
  105. package/src/scan.ts +97 -6
  106. package/src/schema.ts +223 -5
  107. package/src/site-identity.ts +125 -0
  108. package/src/types.ts +72 -7
  109. package/templates/front-door.en.json +426 -0
package/README.md CHANGED
@@ -66,6 +66,8 @@ remains the only required first-day assessment.
66
66
  `VoiceChannel`.
67
67
  - `@clossys/writer/voice-record.template.jsonc` is an annotated,
68
68
  unbound template to copy into a consumer repository and fill in.
69
+ - `@clossys/writer/front-door.en.json` is the shipped English front-door
70
+ catalog (`FRONT_DOOR_COPY_EN`) as a `CopyRegistry` JSON file.
69
71
 
70
72
  ```ts
71
73
  import {
@@ -107,7 +109,8 @@ registered `CopyRecord`. It exits 0 when clean, 1 when it finds traceability
107
109
  issues, and 2 when it cannot run.
108
110
 
109
111
  This package does not resolve a claim's `factRef`, infer tone or grammar, or
110
- ship actual product language. Those decisions remain with the consumer and the
112
+ ship actual product language, except for the 63 English front-door defaults in
113
+ "Front-door copy" below. Those decisions remain with the consumer and the
111
114
  product's facts system.
112
115
 
113
116
  ## Resolving copy for a surface
@@ -136,6 +139,51 @@ unknown ID, locale mismatch, draft/retired entry, missing placeholder value,
136
139
  or unexpected value. Locale fallback belongs to a consumer-owned registry
137
140
  selector, rather than an implicit package policy.
138
141
 
142
+ Both `resolveCopyRef` and `createCopyResolver` take an optional third
143
+ argument, `CopyResolveOptions`:
144
+
145
+ ```ts
146
+ import { createCopyResolver, type CopyRegistry, type CopyResolveOptions } from "@clossys/writer";
147
+
148
+ declare const registry: CopyRegistry;
149
+ const options: CopyResolveOptions = { target: "preview", acceptDelegateInProduction: false, now: new Date() };
150
+ const resolve = createCopyResolver(registry, options);
151
+ ```
152
+
153
+ - `target` (`CopyResolveTarget`, `"preview" | "production"`, default
154
+ `"production"`) — which audience this resolution is for. An owner-approved
155
+ entry resolves on either target. A delegate-approved entry resolves freely
156
+ on `"preview"` but is refused on `"production"`
157
+ (`"delegate-approval-refused"`) unless the caller opts in, with
158
+ `acceptDelegateInProduction: true` or with `approvalPlan`.
159
+ - `acceptDelegateInProduction` (default `false`) — only meaningful when
160
+ `target` is `"production"`; set it to `true` to accept a delegate's
161
+ sign-off as sufficient to publish, not just to preview.
162
+ - `approvalPlan` (`Uint8Array`, optional) — the bytes of an Advisor plan
163
+ record, read by the caller. Consulted only for a delegate-approved entry on
164
+ `"production"` when `acceptDelegateInProduction` is not `true`; see
165
+ "Production authority from an approved plan" below. Passing it together with
166
+ `acceptDelegateInProduction: true`, or passing anything that is not a
167
+ `Uint8Array`, is `"invalid-options"`.
168
+ - `now` (default `new Date()`, evaluated per call) — the clock staleness and
169
+ expiry are measured against; a test fixes it to make an assertion
170
+ deterministic.
171
+
172
+ Three new refusal reasons follow directly from an entry's approval record
173
+ (see "Delegated approval" below for what the record itself contains):
174
+ `"approval-stale"` (the recorded fingerprint no longer matches the entry's
175
+ current text), `"approval-expired"` (a delegate record's `expiresAt` has
176
+ passed), and `"delegate-approval-refused"` (a delegate record on
177
+ `"production"` without `acceptDelegateInProduction: true` or an authorizing
178
+ `approvalPlan`; when a plan was given, the issue's `planRefusal`
179
+ (`CopyResolvePlanRefusal`) says why it did not authorize the entry). A malformed
180
+ `CopyResolveOptions` itself is `"invalid-options"`, checked and refused
181
+ before anything else so a broken options object never silently falls back
182
+ to defaults. An `approved` entry that carries no `approval` record at all
183
+ resolves exactly as it always has, on either target, with no `approval` key
184
+ on the returned `CopyResolution` — this is unchanged, on-by-default
185
+ behavior, not an opt-in.
186
+
139
187
  The default `writer-check` command also runs two registry-only gates against
140
188
  the same `CopyRegistry` file a surface renders from:
141
189
 
@@ -150,6 +198,513 @@ the same `CopyRegistry` file a surface renders from:
150
198
  gate (built-in defaults for `display-heading`, `eyebrow`, and `button` when
151
199
  `maxWords` is omitted).
152
200
 
201
+ ## Site identity copy (`site.name` / `site.tagline`)
202
+
203
+ A site's name and tagline are a reserved copy kind: two entries in the same
204
+ `CopyRegistry` a surface renders from, under the ids `SITE_NAME_COPY_ID`
205
+ (`"site.name"`) and `SITE_TAGLINE_COPY_ID` (`"site.tagline"`, both listed in
206
+ `SITE_IDENTITY_COPY_IDS`). `resolveSiteIdentity` resolves both through
207
+ `resolveCopyRef`, so the approval policy in "Resolving copy for a surface"
208
+ applies to each without a second code path: the entry must be `approved`, a
209
+ recorded approval must still match the entry's current text, a delegate
210
+ approval must not have expired, and a delegate approval is refused on
211
+ `"production"` unless `acceptDelegateInProduction` is set.
212
+
213
+ ```ts
214
+ import { resolveSiteIdentity, type CopyRegistry } from "@clossys/writer";
215
+
216
+ declare const registry: CopyRegistry;
217
+ const result = resolveSiteIdentity(registry, { target: "production", locale: "en" });
218
+
219
+ if (!result.complete) {
220
+ for (const issue of result.issues) console.error(issue.field, issue.reason, issue.message);
221
+ } else if (result.identity) {
222
+ const { name, tagline } = result.identity;
223
+ }
224
+ ```
225
+
226
+ The second argument is `CopyResolveOptions` (`target`, `acceptDelegateInProduction`,
227
+ `now`) plus an optional `locale`, which is passed to the resolver as the
228
+ requested locale of each entry. A malformed options object, or a blank or
229
+ non-string `locale`, is reported as `"invalid-options"` rather than thrown.
230
+
231
+ The result is all-or-nothing. Both entries are always attempted, and every
232
+ problem is returned in one pass, each `SiteIdentityIssue` tagged with the
233
+ `field` (`"name"` or `"tagline"`) it belongs to. `identity` (the two strings)
234
+ and `resolutions` (each field's `CopyResolution`, with its registry, revision,
235
+ locale, source and approval provenance) are present only when both resolved;
236
+ when either fails neither is returned, so a caller cannot end up with half an
237
+ identity. An issue's `reason` is any `CopyResolveIssueReason`, or one of two
238
+ reasons specific to this kind:
239
+
240
+ - `"site-identity-placeholder"` — the entry declares placeholders, or its text
241
+ contains braces the resolver would rewrite. A name and a tagline are literal
242
+ text, not templates.
243
+ - `"site-identity-blank"` — the resolved text is empty after trimming.
244
+
245
+ `resolveSiteIdentity` does not modify the registry and returns the same result
246
+ for the same registry and options (pass `now` to fix the clock). Consuming it
247
+ is a separate change: a publisher page-metadata builder is not part of this
248
+ package, and nothing here reads a brand-facts record.
249
+
250
+ ## Messaging kit (`messaging.pitch.*` / `messaging.boilerplate.*`)
251
+
252
+ The short, medium and long "about us" texts a consumer reuses in press,
253
+ footers and email are a reserved copy kind too: six entries in the same
254
+ `CopyRegistry`, under the ids in `MESSAGING_KIT_COPY_IDS`, in this order:
255
+
256
+ - `messaging.pitch.one-liner`, `messaging.pitch.elevator`,
257
+ `messaging.pitch.paragraph`
258
+ - `messaging.boilerplate.short`, `messaging.boilerplate.medium`,
259
+ `messaging.boilerplate.long`
260
+
261
+ `resolveMessagingKit` resolves each through `resolveCopyRef`, so the approval
262
+ policy in "Resolving copy for a surface" applies unchanged and there is no
263
+ second text store or approval path. No copy ships in this package; the words
264
+ are the consumer's own.
265
+
266
+ ```ts
267
+ import { resolveMessagingKit, type CopyRegistry } from "@clossys/writer";
268
+
269
+ declare const registry: CopyRegistry;
270
+ const result = resolveMessagingKit(registry, { target: "production", locale: "en" });
271
+
272
+ if (!result.complete) {
273
+ for (const issue of result.issues) console.error(issue.field, issue.reason, issue.message);
274
+ } else if (result.kit) {
275
+ const { pitch, boilerplate } = result.kit;
276
+ console.log(pitch.oneLiner, boilerplate.long);
277
+ }
278
+ ```
279
+
280
+ The options are the same as for `resolveSiteIdentity`: `CopyResolveOptions`
281
+ plus an optional `locale`; malformed options are reported as
282
+ `"invalid-options"`, not thrown. The result is all-or-nothing: all six entries
283
+ are attempted, every problem is returned in id order with its `field`
284
+ (`"pitch.oneLiner"` through `"boilerplate.long"`), and `kit` and `resolutions`
285
+ exist only when there are no issues. An issue's `reason` is any
286
+ `CopyResolveIssueReason` (each refusal keeps the resolver's own reason), or
287
+ one of three specific to this kind:
288
+
289
+ - `"messaging-placeholder"` — the entry declares placeholders, or its text
290
+ contains braces the resolver would rewrite.
291
+ - `"messaging-blank"` — the resolved text is empty after trimming.
292
+ - `"messaging-ladder-order"` — `countCopyWords` does not strictly increase
293
+ within a ladder (one-liner, elevator, paragraph; then short, medium, long).
294
+ It is reported on the later field. The two ladders are not compared with
295
+ each other, and there are no other word targets.
296
+
297
+ The placeholder-value reasons of `resolveCopyRef` (`"missing-placeholder-value"`
298
+ and `"unexpected-placeholder-value"`) never surface from this function: a kit
299
+ entry is literal text, so every one of them is reported as
300
+ `"messaging-placeholder"`, once per field.
301
+
302
+ This function judges approval and shape, not wording. For the words of a pitch
303
+ or boilerplate, run `checkCopy` over each resolved text, and run
304
+ `auditClaimsRegister` over the claims register that backs it.
305
+
306
+ An FAQ and a tagline are out of scope (`site.tagline` already exists).
307
+
308
+ ## Front-door copy (`front-door.*`)
309
+
310
+ The words on a sign-in page and the pages around it are a reserved copy kind
311
+ too, so each page does not carry its own. An id is
312
+ `front-door.<state>.<slot>`, where the slot is one of `title`, `description`,
313
+ `label`, `primary`, `secondary`, `notice` or `alt`. This version ships 63
314
+ ids, listed in `FRONT_DOOR_COPY_IDS` (typed as `FrontDoorKey`), as
315
+ `FRONT_DOOR_COPY_EN`, a `CopyRegistry` with the id `front-door`, locale `en`
316
+ and revision `1`; the same registry ships as data in
317
+ `@clossys/writer/front-door.en.json`. They are US English and cover these
318
+ states: sign-in, password, code, reset, activation, request-access,
319
+ forgot-password, identifier-not-found, the field notices (identifier-required,
320
+ password-required, code-required, network), the status notices (unavailable,
321
+ rate-limited, locked, expired, signed-out), the weak-password and
322
+ name-required notices, and the pages error, not-found,
323
+ not-authorized, access-pending and service-unavailable, plus an internal-note
324
+ label:
325
+
326
+ | Id | Text |
327
+ | --- | --- |
328
+ | `front-door.sign-in.title` | Sign in |
329
+ | `front-door.sign-in.description` | Continue to {surface}. |
330
+ | `front-door.sign-in.label` | Email |
331
+ | `front-door.sign-in.primary` | Continue |
332
+ | `front-door.password.title` | Enter your password |
333
+ | `front-door.password.description` | Signing in as {identifier}. |
334
+ | `front-door.password.label` | Password |
335
+ | `front-door.password.primary` | Sign in |
336
+ | `front-door.password.secondary` | Use a different email |
337
+ | `front-door.identifier-not-found.notice` | We couldn’t find an account for that email. Check it and try again. |
338
+ | `front-door.forgot-password.label` | Forgot password? |
339
+ | `front-door.password.notice` | That password isn’t right. Try again or reset it. |
340
+ | `front-door.sign-in.alt` | Sign in to {brand} |
341
+ | `front-door.code.title` | Check your email |
342
+ | `front-door.code.description` | Enter the code sent to {identifier}. |
343
+ | `front-door.code.label` | Code |
344
+ | `front-door.code.primary` | Verify |
345
+ | `front-door.code.secondary` | Send a new code |
346
+ | `front-door.code.notice` | That code isn’t right or has expired. Try again or send a new code. |
347
+ | `front-door.unavailable.notice` | Sign-in isn’t available right now. Try again in a few minutes. |
348
+ | `front-door.rate-limited.notice` | Too many attempts. Wait a few minutes, then try again. |
349
+ | `front-door.locked.notice` | This account is temporarily locked after too many attempts. Try again later. |
350
+ | `front-door.expired.notice` | Your session expired. Sign in again to continue. |
351
+ | `front-door.signed-out.notice` | You’re signed out. |
352
+ | `front-door.error.title` | This page didn’t load |
353
+ | `front-door.error.description` | Something went wrong. Error: {digest}. |
354
+ | `front-door.error.primary` | Try again |
355
+ | `front-door.not-found.title` | Page not found |
356
+ | `front-door.not-found.description` | This page doesn’t exist or has moved. |
357
+ | `front-door.not-found.primary` | Go to {surface} |
358
+ | `front-door.not-authorized.title` | You don’t have access |
359
+ | `front-door.not-authorized.description` | {identifier} doesn’t have access to {surface}. |
360
+ | `front-door.not-authorized.primary` | Switch account |
361
+ | `front-door.not-authorized.secondary` | Sign out |
362
+ | `front-door.access-pending.title` | Access pending |
363
+ | `front-door.access-pending.description` | Your access to {surface} awaits approval. Check back later. |
364
+ | `front-door.access-pending.primary` | Sign out |
365
+ | `front-door.service-unavailable.title` | {surface} isn’t available |
366
+ | `front-door.service-unavailable.description` | This is temporary. Try again soon. |
367
+ | `front-door.service-unavailable.primary` | Try again |
368
+ | `front-door.request-access.description` | Don’t have an account? |
369
+ | `front-door.request-access.label` | {requestAccessLabel} |
370
+ | `front-door.reset.title` | Reset your password |
371
+ | `front-door.reset.description` | Enter your email and we’ll send you a code. |
372
+ | `front-door.reset.label` | New password |
373
+ | `front-door.reset.primary` | Send code |
374
+ | `front-door.reset.secondary` | Back to sign in |
375
+ | `front-door.reset.notice` | Password updated. Sign in with your new password. |
376
+ | `front-door.activation.title` | Set up your account |
377
+ | `front-door.activation.description` | You’re invited to {surface}. Choose a password to finish. |
378
+ | `front-door.activation.primary` | Activate account |
379
+ | `front-door.activation.notice` | This invitation has expired or was already used. Ask for a new invitation. |
380
+ | `front-door.internal-note.label` | Internal |
381
+ | `front-door.identifier-required.notice` | Enter your email. |
382
+ | `front-door.password-required.notice` | Enter your password. |
383
+ | `front-door.code-required.notice` | Enter the code. |
384
+ | `front-door.network.notice` | Couldn’t reach {surface}. Check your connection and try again. |
385
+ | `front-door.password-weak.notice` | That password isn’t strong enough. Use a longer one you haven’t used anywhere else. |
386
+ | `front-door.reset-code.primary` | Reset password |
387
+ | `front-door.activation.label` | Password |
388
+ | `front-door.activation-first-name.label` | First name |
389
+ | `front-door.activation-last-name.label` | Last name |
390
+ | `front-door.name-required.notice` | Enter your first and last name. |
391
+
392
+ A `{token}` in a text is a noun from the closed set `FRONT_DOOR_NOUNS`:
393
+ `brand`, `surface`, `identifier`, `digest` and `requestAccessLabel` (typed as
394
+ `FrontDoorNoun`; a caller's values are `FrontDoorNouns`). The defaults are
395
+ marked `approved` and carry no approval record: that is the package's own
396
+ statement about shipped defaults, not a consumer's sign-off.
397
+
398
+ `resolveFrontDoorCopy(key, nouns)` resolves one entry through
399
+ `resolveCopyRef` and never throws. It hands the resolver only the nouns that
400
+ entry declares, so a known noun the entry does not use is dropped. The result
401
+ is `{ complete, text?, resolution?, issues }`; `text` and `resolution` exist
402
+ only when `issues` is empty. An issue's `reason` is one of:
403
+
404
+ - `"unknown-copy-id"` — the id is not in `FRONT_DOOR_COPY_IDS`.
405
+ - `"missing-noun"` — a noun the entry declares is absent or blank.
406
+ - `"unknown-noun"` — a noun name outside `FRONT_DOOR_NOUNS`.
407
+
408
+ ```ts
409
+ import { resolveFrontDoorCopy } from "@clossys/writer";
410
+
411
+ const heading = resolveFrontDoorCopy("front-door.password.title", {});
412
+ const line = resolveFrontDoorCopy("front-door.password.description", {
413
+ identifier: "ana@example.test",
414
+ brand: "Acme",
415
+ });
416
+
417
+ if (line.complete) {
418
+ console.log(heading.text, line.text); // "Enter your password" "Signing in as ana@example.test."
419
+ } else {
420
+ for (const issue of line.issues) console.error(issue.reason, issue.noun, issue.message);
421
+ }
422
+ ```
423
+
424
+ A site overrides one entry by registering the same id in its own registry and
425
+ resolving it with `resolveCopyRef`; the defaults are the fallback, not a lock.
426
+ Other states and a JSON file of the catalog are not in this version.
427
+
428
+ ## Delegated approval — who approved this copy, and is it still that text?
429
+
430
+ `status: "approved"` says an entry may render. It does not say who decided
431
+ that, when, or whether the sentence has since changed underneath the
432
+ decision. `CopyRegistryEntry.approval` (`CopyApproval`) answers all three.
433
+ `writer-check approve` writes the record, so nobody has to hand-edit it into
434
+ a registry or into code; `writer-check approval-state` reports approval state
435
+ set in source instead. `textFingerprint` is a content hash rather than a
436
+ human-maintained counter for the same reason `fingerprint.ts` gives for
437
+ translation provenance.
438
+
439
+ ```json
440
+ {
441
+ "id": "site.home.title",
442
+ "text": "Home title copy.",
443
+ "context": "home hero",
444
+ "status": "approved",
445
+ "approval": {
446
+ "approvedBy": "delegate",
447
+ "approvedAt": "2026-09-27T00:00:00.000Z",
448
+ "textFingerprint": "bb46f6c7794911342f760f6e9e46f1f6ca31d0d306c9b301a1069f4b6ff6966c",
449
+ "fingerprintAlgorithm": "sha256",
450
+ "delegate": { "id": "delegate-a", "scope": ["site.home"] },
451
+ "pendingOwnerReview": true,
452
+ "expiresAt": "2026-10-27T00:00:00.000Z"
453
+ }
454
+ }
455
+ ```
456
+
457
+ (`textFingerprint` above is `computeCopyFingerprint("Home title copy.")` —
458
+ a 64-character `sha256` hex digest of that entry's own `text`, computed by
459
+ `writer-check approve`.)
460
+
461
+ - **`approvedBy`** (`CopyApprover`, `"owner" | "delegate"`) — who recorded
462
+ this approval.
463
+ - **`textFingerprint`**/**`fingerprintAlgorithm`** — pin the record to the
464
+ EXACT text that was approved. The moment `text` changes, a fresh
465
+ `computeCopyFingerprint(entry.text)` no longer matches, and the record is
466
+ `"approval-stale"` — an error, not a warning — until it is approved again.
467
+ This is the same content-derived-over-hand-maintained argument
468
+ `fingerprint.ts` already makes for translation staleness, applied to
469
+ approval staleness instead.
470
+ - **`delegate`** (`CopyDelegateScope`, `{ id, scope }`) — required when
471
+ `approvedBy` is `"delegate"`, forbidden when it is `"owner"`. `id` is an
472
+ opaque identifier, never a personal name. `scope` is one or more
473
+ dot-separated entry-id namespaces the delegate may approve: an entry is in
474
+ scope when its id equals a scope item or starts with `item + "."` —
475
+ `["site.home"]` covers `site.home` and `site.home.title`, never
476
+ `site.homepage.title`. `isEntryInDelegateScope(entryId, scope)` is the
477
+ exported predicate.
478
+ - **`pendingOwnerReview`** — required (and only meaningful) on a delegate
479
+ record. A delegate's approval is enough for the entry to resolve — see
480
+ `target`/`acceptDelegateInProduction` above — but it is a warning
481
+ (`"approval-pending-owner-review"`), not an error, until an owner durably
482
+ confirms it. Approving the SAME entry again with `--by owner` REPLACES the
483
+ delegate's record with the owner's own — that replacement, not a separate
484
+ "review" action, is how a pending delegate approval is durably confirmed.
485
+ - **`expiresAt`** — delegate records only, an ISO 8601 UTC timestamp
486
+ strictly after `approvedAt`. Once `now` reaches it, the record is
487
+ `"approval-expired"` — an error, mutually exclusive with `"approval-stale"`
488
+ (staleness, the more fundamental problem, takes priority when a record is
489
+ somehow both).
490
+
491
+ ### Production authority from an approved plan
492
+
493
+ `acceptDelegateInProduction: true` is a bare flag: it records no reason. The
494
+ other way to let a delegate-approved entry resolve on `"production"` is
495
+ `approvalPlan`, the bytes of an Advisor plan record whose own approval covers
496
+ the delegation. The plan declares it in an optional top-level member,
497
+ `delegatedCopyApproval` (`{ "target": "production", "scopes": ["site.home"] }`),
498
+ defined by the plan contract and its
499
+ [digest definition](https://github.com/clossys/foundry/blob/main/docs/contracts/advisor-plan-digest.md);
500
+ the [contract](https://github.com/clossys/foundry/blob/main/docs/contracts/advisor-plan.json)
501
+ and the digest page live in the public repository, not shipped in this
502
+ package. `scopes` is optional; when present it is a non-empty list of copy
503
+ entry-id namespaces (`site.home`, not `site.home.*`). Writer reads no file:
504
+ the caller passes the bytes.
505
+
506
+ ```ts
507
+ import { createCopyResolver, planDelegateCopyAuthority, type CopyRegistry } from "@clossys/writer";
508
+
509
+ declare const registry: CopyRegistry;
510
+ declare const planBytes: Uint8Array; // the plan file's bytes, read by the caller
511
+ const authority = planDelegateCopyAuthority(planBytes);
512
+ if (!authority.authorized) throw new Error(`plan does not authorize delegate copy: ${authority.refusal}`);
513
+ const resolved = createCopyResolver(registry, { target: "production", approvalPlan: planBytes })({ id: "home.title" });
514
+ const digest = resolved?.approval?.approvedBy === "delegate" ? resolved.approval.authorizingPlanDigest : undefined;
515
+ ```
516
+
517
+ A delegate-approved entry resolves on `"production"` under `approvalPlan` when
518
+ Writer itself has established each of these from the bytes it was given:
519
+
520
+ - the bytes are strict JSON: valid UTF-8, no byte-order mark, and no key
521
+ repeated at any depth;
522
+ - the plan they encode passes the plan contract's schema and its code rules
523
+ R1 to R12 and, for `delegatedCopyApproval`, the schema's own checks (a
524
+ non-empty `scopes` list of well-formed namespaces, a known `target`, no
525
+ unknown key). Repeating a scope item is accepted and only redundant;
526
+ - the plan declares `delegatedCopyApproval`;
527
+ - the plan's latest decision by time has chosen `"approved"` and names a
528
+ `subjectDigest` equal to the canonical digest Writer computed from those
529
+ same bytes. Decisions that tie at the same instant must all approve and
530
+ name one digest; a decision time that does not parse authorizes nothing.
531
+ The digest covers `delegatedCopyApproval` and its `scopes`, so adding,
532
+ removing or editing either after an approval leaves the plan unapproved
533
+ for this purpose until a new approval names the new digest;
534
+ - the entry's id is inside the declared `scopes`, when scopes are declared.
535
+ An entry is inside a scope when its id equals a scope item or starts with
536
+ `item + "."`, as for a delegate's own scope: `site.home` covers
537
+ `site.home` and `site.home.title`, not `site.homepage.title`. Without
538
+ `scopes` the declaration covers every entry. The entry's own
539
+ `approval.delegate.scope` is enforced as before, so both lists must hold.
540
+
541
+ `planDelegateCopyAuthority(bytes)` is the pure function that does this. It
542
+ returns a `PlanDelegateCopyAuthority`: `{ authorized: true, planDigest,
543
+ scopes? }` (`scopes` present only when the plan declares them) or
544
+ `{ authorized: false, refusal, violations? }`, where `refusal` is a
545
+ `PlanDelegateCopyRefusal` and `violations` (only with `"plan-invalid"`) lists
546
+ each failed rule and position, never a value. Nothing in the result quotes the
547
+ plan. The refusal codes:
548
+
549
+ - `"plan-not-bytes"` — the argument is not a `Uint8Array` (a string, a parsed
550
+ object, a `DataView` and a `Uint16Array` are all refused).
551
+ - `"plan-unreadable"` — the bytes are not strict JSON (invalid UTF-8, a
552
+ byte-order mark, a syntax error, or a repeated key).
553
+ - `"plan-invalid"` — the plan fails the contract's schema or code rules, which
554
+ includes a `delegatedCopyApproval` that is malformed, has an empty `scopes`
555
+ list or an unknown `target`, and a decision time that does not parse.
556
+ - `"delegated-copy-approval-absent"` — the plan does not declare
557
+ `delegatedCopyApproval`.
558
+ - `"no-decisions"` — the plan has no decisions.
559
+ - `"decision-time-unparseable"` — a decision's time does not parse (a valid
560
+ plan cannot reach this; it is kept as a second check).
561
+ - `"latest-decision-not-approved"` — a decision at the latest time has not
562
+ chosen `"approved"`, so a later deferral or rejection withdraws the grant.
563
+ - `"approval-without-subject-digest"` — an approving decision at the latest
564
+ time names no well-formed `subjectDigest`, so it binds no bytes.
565
+ - `"latest-decisions-disagree"` — decisions tied at the latest time name
566
+ different digests.
567
+ - `"subject-digest-mismatch"` — the latest approval names a digest other than
568
+ this plan's own: the plan changed after it, or the approval belongs to
569
+ another plan.
570
+
571
+ `resolveCopyRef` adds one more code of its own, in `CopyResolvePlanRefusal`
572
+ (`PlanDelegateCopyRefusal` plus this value): `"entry-outside-plan-scopes"` —
573
+ the plan authorizes delegate copy but its `scopes` do not include the entry.
574
+
575
+ **What the resolution reports.** `CopyResolution.approval` is a
576
+ `CopyResolutionApproval`: `{ approvedBy: "owner", pendingOwnerReview: false }`
577
+ or `{ approvedBy: "delegate", pendingOwnerReview, authorizingPlanDigest? }`.
578
+ `authorizingPlanDigest` is the plan's canonical digest and is present only when
579
+ an `approvalPlan` authorized the entry on `"production"`. It is absent when
580
+ `acceptDelegateInProduction` authorized it and on `"preview"`, so a
581
+ plan-authorized resolution can be told from a flag-authorized one by whether
582
+ the key is a string.
583
+
584
+ **Precedence.** An entry's own `"approval-stale"` and `"approval-expired"`
585
+ outrank every plan outcome. `acceptDelegateInProduction: true` together with
586
+ `approvalPlan` is `"invalid-options"`, so two authorities never compete. On
587
+ `"preview"` the plan is accepted and ignored, and no digest is reported; the
588
+ plan is likewise unread for an owner-approved entry or one with no approval
589
+ record. It is evaluated on each call and not cached.
590
+
591
+ **What this does not prove.**
592
+
593
+ - Who wrote the decision. Nothing in the file shows that the latest decision is
594
+ genuine; that depends on where the plan file is committed and who could
595
+ commit it. Anyone able to write the file can append an approval naming the
596
+ digest.
597
+ - Where the bytes came from. The plan is caller-supplied bytes, and Writer
598
+ verifies no provenance. A caller that controls the call could equally pass
599
+ `acceptDelegateInProduction: true`; the plan route adds a recorded,
600
+ digest-bound reason, not a boundary against the caller.
601
+ - Any link between the registry and the plan. The plan names no registry,
602
+ revision or entry fingerprint, so it covers delegate approvals in scope that
603
+ were recorded before or after the plan's approval. Entry-level staleness and
604
+ expiry still apply.
605
+ - Expiry. The plan's approval has none: it holds until the file changes or a
606
+ later decision withdraws it.
607
+ - More than one subject. One latest decision binds one subject, so a decision
608
+ naming an apply-bundle digest grants nothing here.
609
+ - A narrow grant. A `delegatedCopyApproval` without `scopes` covers every
610
+ entry; the grant is visible only as the missing member.
611
+
612
+ ### `writer-check approve` — write or revoke a record
613
+
614
+ ```bash
615
+ writer-check approve <registry-file> <entry-id>... --by owner
616
+ writer-check approve <registry-file> <entry-id>... --by delegate --delegate <id> --scope <ns>[,<ns>...] [--expires <ISO>]
617
+ writer-check approve <registry-file> <entry-id>... --revoke
618
+ ```
619
+
620
+ Every field the command writes is either computed (`approvedAt` from the
621
+ clock, `textFingerprint` from the entry's current `text`) or copied from an
622
+ argument the command validated. Approving sets `status: "approved"`; a
623
+ delegate record is written with `pendingOwnerReview: true`. Multiple entry
624
+ ids are **all-or-nothing**: if any named id is unknown, retired, or (for a
625
+ delegate approval) outside `--scope`, every problem is printed and nothing
626
+ is written, including for the ids that would otherwise have succeeded. The
627
+ command leaves every other field, the key order and `revision` as they
628
+ were, and writes 2-space JSON to a temp file in the same directory before
629
+ renaming it into place. `--revoke` returns every named entry to `"draft"`
630
+ and removes its `approval` record.
631
+
632
+ Exit codes: `0` written (every named entry approved or revoked, the file on
633
+ disk now reflects it), `1` refused (an unknown, retired, or out-of-scope
634
+ entry id was named; nothing written), `2` could not run (bad arguments, or
635
+ the registry file is missing/unreadable/not valid JSON/fails schema
636
+ validation; nothing written).
637
+
638
+ ### `writer-check approval-state` — is the registry current, and does source respect it?
639
+
640
+ ```bash
641
+ writer-check approval-state <registry-file> <scan-dir> [--extensions <ext>[,<ext>...]] [--format json] [--now <ISO>]
642
+ ```
643
+
644
+ Reports approval state from two independent angles in one run, because
645
+ either can fail without the other noticing:
646
+
647
+ - **Registry findings** (`assessCopyApprovals`, above): `"approval-stale"`
648
+ and `"approval-expired"` are errors; a missing record
649
+ (`"approval-record-missing"`) or a delegate record still
650
+ `pendingOwnerReview` (`"approval-pending-owner-review"`) is a warning —
651
+ legitimately approved for now, just not yet durably so.
652
+ - **Source findings** (`scanApprovalBypass`/`checkApprovalBypass`,
653
+ `approval-bypass.ts`): does consumer code in `scan-dir` route copy through
654
+ the resolver, or around it? `"approval-set-in-code"` — the code itself
655
+ declares approval (`status: "approved"`, an `approvedBy`/
656
+ `pendingOwnerReview` property or member assignment) — and
657
+ `"copy-read-without-resolver"` — the code imports the registry file and
658
+ reads its content some way other than passing it whole to
659
+ `createCopyResolver`/`resolveCopyRef`/`validateCopyRegistryShape`, or
660
+ calling `parseCopyRegistry` only when its return value is passed to
661
+ `createCopyResolver` or `resolveCopyRef` — are both errors. An unresolvable registry
662
+ load (a dynamic `import()`/`require()`, an unbound `require`, a
663
+ re-export) or a string that merely names the registry file is reported as
664
+ `unchecked`, not a finding: an incomplete picture, never assumed clean.
665
+
666
+ **The coupled-file limit, stated plainly**: only files that import
667
+ `@clossys/writer` (any subpath) or import the registry file itself are
668
+ examined. A file that copies registry data without importing either —
669
+ pasted JSON, data fetched at runtime, a registry handed in from an
670
+ uncoupled module — is not examined by this gate. Other known gaps: specifier
671
+ matching is exact (an extension-less or aliased specifier is not
672
+ recognised) and the registry-file match is by basename, which is broad
673
+ enough that a distinctive registry filename (not `index.json`) is worth
674
+ choosing; there is no scope tracking, so a local variable that happens to
675
+ share a flagged binding's name is flagged too; a type assertion or `satisfies`
676
+ between the registry binding and the closing `)` of an allowed resolver call
677
+ is refused (for example `createCopyResolver(registry as CopyRegistry)` is not
678
+ a pass — the resolver accepts `unknown`, so no cast is needed); and only a directly written `"approved"` string
679
+ literal is caught — a value built at runtime is not.
680
+
681
+ **Verdict precedence**: the same "a violation outranks an incomplete
682
+ picture" ternary `checkAddressability` and `checkPassageComposition` both
683
+ use. `"violated"` whenever at least one error-severity finding exists
684
+ (registry or source), regardless of what is unchecked; `"indeterminate"`
685
+ only with zero error findings and something left unchecked, unparseable, or
686
+ zero files scanned; `"satisfied"` otherwise. Warnings are reported but do
687
+ not change the verdict.
688
+
689
+ Exit codes: `1` any error-severity finding (a registry error or any
690
+ approval-bypass finding); else `2` any unchecked registry load/reference,
691
+ any file that failed to parse, or zero files scanned; else `0`. Warnings
692
+ never change the exit code.
693
+
694
+ `--format json` prints exactly one object to stdout:
695
+
696
+ ```
697
+ {
698
+ verdict: "violated" | "indeterminate" | "satisfied",
699
+ findings: Array<
700
+ | { source: "registry", rule, severity, entryId, message }
701
+ | { source: "source", rule, severity, file, line, message }
702
+ >,
703
+ unchecked: Array<{ file, line, kind, detail }>,
704
+ counts: { errors, warnings, unchecked, filesScanned, coupledFiles }
705
+ }
706
+ ```
707
+
153
708
  ## Where this package sits on i18n
154
709
 
155
710
  "i18n" is two different things, and this package deliberately does only one
@@ -680,12 +1235,28 @@ The root entry point exports the copy registry and traceability surface:
680
1235
  `createCopyResolver`, `resolveCopyRef`, `checkCopyRecord`, `CopyRecord`,
681
1236
  `CopyRegistry`, `CopyEntry`, `CopyRegistryEntry`, `CopyRef`, `CopyResolution`,
682
1237
  `CopyResolver`, `CopySource`, `CopyLocale`, `CopyValue`, `CopyEntryStatus`,
683
- `CopyResolveIssue`, `CopyResolveIssueReason`, `CopyResolveResult`,
1238
+ `CopyResolveIssue`, `CopyResolveIssueReason`, `CopyResolveOptions`,
1239
+ `CopyResolveResult`, `CopyResolveTarget`,
684
1240
  `CopyEntryId`, `CopyFinding`, `CopyRegistryReadIssue`,
685
1241
  `CopyRegistryReadIssueReason`, `CopyRegistryReadResult`,
686
1242
  `CopyEntryCheckResult`, `CopyEntrySkip`, `CopyRecordCheckOptions`,
687
1243
  `CopyRecordCheckReport`, `CopyRecordFinding`, and
688
1244
  `CopyRecordWaivedFinding`.
1245
+ - Site identity (see above): `resolveSiteIdentity`, `SITE_NAME_COPY_ID`,
1246
+ `SITE_TAGLINE_COPY_ID`, `SITE_IDENTITY_COPY_IDS`, `SiteIdentityField`,
1247
+ `SiteIdentityIssue`, `SiteIdentityIssueReason`, `SiteIdentityOptions`, and
1248
+ `SiteIdentityResolution`.
1249
+ - Messaging kit (see above): `resolveMessagingKit`, `MESSAGING_KIT_COPY_IDS`,
1250
+ `MESSAGING_PITCH_ONE_LINER_COPY_ID`, `MESSAGING_PITCH_ELEVATOR_COPY_ID`,
1251
+ `MESSAGING_PITCH_PARAGRAPH_COPY_ID`, `MESSAGING_BOILERPLATE_SHORT_COPY_ID`,
1252
+ `MESSAGING_BOILERPLATE_MEDIUM_COPY_ID`, `MESSAGING_BOILERPLATE_LONG_COPY_ID`,
1253
+ `MessagingKit`, `MessagingKitField`, `MessagingKitIssue`,
1254
+ `MessagingKitIssueReason`, `MessagingKitOptions`, `MessagingKitResolution`,
1255
+ and `MessagingKitResolutions`.
1256
+ - Front-door copy (see above): `resolveFrontDoorCopy`, `isFrontDoorCopyId`,
1257
+ `FRONT_DOOR_COPY_EN`, `FRONT_DOOR_COPY_IDS`, `FRONT_DOOR_NOUNS`,
1258
+ `FrontDoorKey`, `FrontDoorNoun`, `FrontDoorNouns`, `FrontDoorCopyIssue`,
1259
+ `FrontDoorCopyIssueReason`, and `FrontDoorCopyResolution`.
689
1260
  - Translation provenance and fingerprinting (see "Where this package sits
690
1261
  on i18n" above): `CopyTranslationProvenance`, `computeCopyFingerprint`,
691
1262
  and `COPY_FINGERPRINT_ALGORITHM`.
@@ -726,6 +1297,20 @@ The root entry point exports the copy registry and traceability surface:
726
1297
  `PassageRegistryReadResult`, `PassageTermReference`,
727
1298
  `PassageUnclassifiedItem`, `PassageVerdict`, `PassageViolation`, and
728
1299
  `PassageViolationRule`.
1300
+ - Delegated approval (see above): `CopyApproval`, `CopyApprover`,
1301
+ `CopyDelegateScope`, `assessCopyApprovals`, `isEntryInDelegateScope`,
1302
+ `CopyApprovalFinding`, `CopyApprovalFindingRule`, `checkApprovalBypass`,
1303
+ `extractApprovalBypass`, `scanApprovalBypass`, `ApprovalBypassExtractResult`,
1304
+ `ApprovalBypassFinding`, `ApprovalBypassGateResult`, `ApprovalBypassRule`,
1305
+ `ApprovalBypassScanOptions`, `ApprovalBypassScanResult`,
1306
+ `ApprovalBypassUncheckedItem`, and `ApprovalBypassVerdict`. The commands
1307
+ that write and report on this state, `writer-check approve` and
1308
+ `writer-check approval-state`, are CLI-only and not exported from
1309
+ `index.ts`.
1310
+ - Production authority from an approved plan (see above):
1311
+ `planDelegateCopyAuthority`, `PlanDelegateCopyAuthority`,
1312
+ `PlanDelegateCopyRefusal`, `CopyResolutionApproval`, and
1313
+ `CopyResolvePlanRefusal`.
729
1314
 
730
1315
  The voice names described under Public entry points are re-exported from the
731
1316
  root and from `@clossys/writer/voice`, including the rule-vocabulary