@clossys/writer 0.4.0 → 0.5.1

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