@clossys/writer
v0.5.0
Published
The writer role: is it well said? A consumer-owned language system: voice rules, glossary and claims validation, addressable copy records, source traceability, locale coverage, and passage-layer composition — five gates reachable from one bin. Ships machi
Maintainers
Readme
@clossys/writer
The writer role — is it well said? This package is named for the job,
not the artifact. The records it owns are still copy records, and the
vocabulary inside it (CopyRegistry, CopyRef, checkCopyRecord, the
copy-record schema, and every voice/glossary/claim term) is unchanged: a
role owns artifacts, and renaming the role does not rename what it reasons
about.
@clossys/writer owns the language system for a product: its voice
rules, glossary, claims register, addressable copy records, and source
traceability checks. It ships the machinery and a deliberately unbound voice
template; each consumer supplies its own language and facts.
npm install @clossys/writerThis package is published to the public npm registry, https://registry.npmjs.org.
Installing it needs no authentication: no npm token, no .npmrc registry
override, and no GitHub credential of any kind.
Approved copy coverage rate
Independent consumer evidence shows the position's owned metric meets its
setpoint over the declared review cadence. The owned metric is approved
copy coverage rate, computed by assessApprovedCopyCoverageRate(). An
empty evaluated set is indeterminate, never a perfect rate of 1.
checkCopyRecord and checkCopyTraceability remain the gates they are;
neither is this rate. This package does not measure consumer evidence and
does not close the loop. A green run of this package's tests is not a close.
import { assessApprovedCopyCoverageRate } from "@clossys/writer";
const report = assessApprovedCopyCoverageRate(input);writer-rate-check assessment.jsonThe command prints JSON and exits 0 for satisfied, 1 for violated, and
2 for indeterminate, unreadable, or invalid input.
This package declares that command as its first-day assessment surface in its own manifest:
"foundry": { "assessment": { "bin": "writer-rate-check", "invocation": "single-json-input" } }Onboarding discovers that declaration from the installed manifest and never
infers a surface. writer-check remains the multi-mode CLI and is not the
assessment surface. Writer is not a required first-day role; Advisor
remains the only required first-day assessment.
Public entry points
@clossys/writerexposes both voice and copy-record APIs.@clossys/writer/voiceexposes only the voice contract:checkCopy,auditClaimsRegister,isCiBlockingSeverity,checkPatternSafety,parseVoiceRecord,validateVoiceRecordShape,VOICE_FIELDS,VOICE_SEVERITIES, and their types — including the rule vocabulary described below:PatternRule,VoicePattern,VoiceSeverity,VoiceChannel.@clossys/writer/voice-record.template.jsoncis an annotated, unbound template to copy into a consumer repository and fill in.@clossys/writer/front-door.en.jsonis the shipped English front-door catalog (FRONT_DOOR_COPY_EN) as aCopyRegistryJSON file.
import {
createCopyResolver,
checkCopy,
checkCopyRecord,
type CopyRegistry,
type VoiceRecord,
} from "@clossys/writer";
const voice: VoiceRecord = {
id: "acme-app",
rules: {
person: { description: "second person", forbiddenPronouns: ["we", "our"] },
tense: { description: "present tense", forbiddenMarkers: ["will"] },
formality: "neutral",
tone: ["direct"],
},
glossary: [{ term: "utilize", status: "forbidden", reason: "prefer plain language" }],
claims: [],
};
const record: CopyRegistry = {
id: "acme-app",
locale: "en",
revision: "2026-08-11",
source: { kind: "consumer", reference: "editorial/revisions/42" },
entries: [{ id: "home.title", text: "Plan your week.", context: "home page heading", status: "approved" }],
};
checkCopy(voice, record.entries[0].text);
checkCopyRecord(record, voice);
const resolveCopy = createCopyResolver(record);
resolveCopy({ id: "home.title" });writer-check scans a source tree and checks its user-facing literals against a
registered CopyRecord. It exits 0 when clean, 1 when it finds traceability
issues, and 2 when it cannot run.
This package does not resolve a claim's factRef, infer tone or grammar, or
ship actual product language, except for the 63 English front-door defaults in
"Front-door copy" below. Those decisions remain with the consumer and the
product's facts system.
Resolving copy for a surface
CopyRecord is the minimal schema used by source scanning and voice checking.
Rendered output must use the stronger CopyRegistry: one locale, a revision,
opaque source provenance, and an explicit lifecycle state for every entry.
Only approved entries resolve. This prevents a surface manifest from claiming
traceability for draft, retired, unversioned, or unlocalised text.
import { createCopyResolver, type CopyRef, type CopyRegistry } from "@clossys/writer";
declare const registry: CopyRegistry;
const ref: CopyRef = { id: "account.greeting", locale: "en", values: { name: "Ada" } };
const resolved = createCopyResolver(registry)(ref);
if (!resolved) throw new Error("Required copy could not be resolved");
// resolved.text goes to the renderer. Its registry/revision/locale/source and
// entry identifier can be retained structurally by
// surface's createResolvedOutputManifest helper.The resolver is strict: it fails closed for an invalid runtime registry, unknown ID, locale mismatch, draft/retired entry, missing placeholder value, or unexpected value. Locale fallback belongs to a consumer-owned registry selector, rather than an implicit package policy.
Both resolveCopyRef and createCopyResolver take an optional third
argument, CopyResolveOptions:
import { createCopyResolver, type CopyRegistry, type CopyResolveOptions } from "@clossys/writer";
declare const registry: CopyRegistry;
const options: CopyResolveOptions = { target: "preview", acceptDelegateInProduction: false, now: new Date() };
const resolve = createCopyResolver(registry, options);target(CopyResolveTarget,"preview" | "production", default"production") — which audience this resolution is for. An owner-approved entry resolves on either target. A delegate-approved entry resolves freely on"preview"but is refused on"production"("delegate-approval-refused") unless the caller opts in, withacceptDelegateInProduction: trueor withapprovalPlan.acceptDelegateInProduction(defaultfalse) — only meaningful whentargetis"production"; set it totrueto accept a delegate's sign-off as sufficient to publish, not just to preview.approvalPlan(Uint8Array, optional) — the bytes of an Advisor plan record, read by the caller. Consulted only for a delegate-approved entry on"production"whenacceptDelegateInProductionis nottrue; see "Production authority from an approved plan" below. Passing it together withacceptDelegateInProduction: true, or passing anything that is not aUint8Array, is"invalid-options".now(defaultnew Date(), evaluated per call) — the clock staleness and expiry are measured against; a test fixes it to make an assertion deterministic.
Three new refusal reasons follow directly from an entry's approval record
(see "Delegated approval" below for what the record itself contains):
"approval-stale" (the recorded fingerprint no longer matches the entry's
current text), "approval-expired" (a delegate record's expiresAt has
passed), and "delegate-approval-refused" (a delegate record on
"production" without acceptDelegateInProduction: true or an authorizing
approvalPlan; when a plan was given, the issue's planRefusal
(CopyResolvePlanRefusal) says why it did not authorize the entry). A malformed
CopyResolveOptions itself is "invalid-options", checked and refused
before anything else so a broken options object never silently falls back
to defaults. An approved entry that carries no approval record at all
resolves exactly as it always has, on either target, with no approval key
on the returned CopyResolution — this is unchanged, on-by-default
behavior, not an opt-in.
The default writer-check command also runs two registry-only gates against
the same CopyRegistry file a surface renders from:
- Render-store parity (
checkRenderRegistryParity):record-filemust be aCopyRegistry— the storecreateCopyResolverreads at render, not a plainCopyRecordor a second in-memory-only store.--render-registry <file>may name the path the surface passes tocreateCopyResolver; after realpath it must be the same file asrecord-file. - Treatment word budgets (
checkTreatmentWordBudgets,countCopyWords,DEFAULT_TREATMENT_WORD_BUDGETS): registry entries may declaretreatmentand optionalmaxWords. Approved copy that exceeds the budget fails the gate (built-in defaults fordisplay-heading,eyebrow, andbuttonwhenmaxWordsis omitted).
Site identity copy (site.name / site.tagline)
A site's name and tagline are a reserved copy kind: two entries in the same
CopyRegistry a surface renders from, under the ids SITE_NAME_COPY_ID
("site.name") and SITE_TAGLINE_COPY_ID ("site.tagline", both listed in
SITE_IDENTITY_COPY_IDS). resolveSiteIdentity resolves both through
resolveCopyRef, so the approval policy in "Resolving copy for a surface"
applies to each without a second code path: the entry must be approved, a
recorded approval must still match the entry's current text, a delegate
approval must not have expired, and a delegate approval is refused on
"production" unless acceptDelegateInProduction is set.
import { resolveSiteIdentity, type CopyRegistry } from "@clossys/writer";
declare const registry: CopyRegistry;
const result = resolveSiteIdentity(registry, { target: "production", locale: "en" });
if (!result.complete) {
for (const issue of result.issues) console.error(issue.field, issue.reason, issue.message);
} else if (result.identity) {
const { name, tagline } = result.identity;
}The second argument is CopyResolveOptions (target, acceptDelegateInProduction,
now) plus an optional locale, which is passed to the resolver as the
requested locale of each entry. A malformed options object, or a blank or
non-string locale, is reported as "invalid-options" rather than thrown.
The result is all-or-nothing. Both entries are always attempted, and every
problem is returned in one pass, each SiteIdentityIssue tagged with the
field ("name" or "tagline") it belongs to. identity (the two strings)
and resolutions (each field's CopyResolution, with its registry, revision,
locale, source and approval provenance) are present only when both resolved;
when either fails neither is returned, so a caller cannot end up with half an
identity. An issue's reason is any CopyResolveIssueReason, or one of two
reasons specific to this kind:
"site-identity-placeholder"— the entry declares placeholders, or its text contains braces the resolver would rewrite. A name and a tagline are literal text, not templates."site-identity-blank"— the resolved text is empty after trimming.
resolveSiteIdentity does not modify the registry and returns the same result
for the same registry and options (pass now to fix the clock). Consuming it
is a separate change: a publisher page-metadata builder is not part of this
package, and nothing here reads a brand-facts record.
Messaging kit (messaging.pitch.* / messaging.boilerplate.*)
The short, medium and long "about us" texts a consumer reuses in press,
footers and email are a reserved copy kind too: six entries in the same
CopyRegistry, under the ids in MESSAGING_KIT_COPY_IDS, in this order:
messaging.pitch.one-liner,messaging.pitch.elevator,messaging.pitch.paragraphmessaging.boilerplate.short,messaging.boilerplate.medium,messaging.boilerplate.long
resolveMessagingKit resolves each through resolveCopyRef, so the approval
policy in "Resolving copy for a surface" applies unchanged and there is no
second text store or approval path. No copy ships in this package; the words
are the consumer's own.
import { resolveMessagingKit, type CopyRegistry } from "@clossys/writer";
declare const registry: CopyRegistry;
const result = resolveMessagingKit(registry, { target: "production", locale: "en" });
if (!result.complete) {
for (const issue of result.issues) console.error(issue.field, issue.reason, issue.message);
} else if (result.kit) {
const { pitch, boilerplate } = result.kit;
console.log(pitch.oneLiner, boilerplate.long);
}The options are the same as for resolveSiteIdentity: CopyResolveOptions
plus an optional locale; malformed options are reported as
"invalid-options", not thrown. The result is all-or-nothing: all six entries
are attempted, every problem is returned in id order with its field
("pitch.oneLiner" through "boilerplate.long"), and kit and resolutions
exist only when there are no issues. An issue's reason is any
CopyResolveIssueReason (each refusal keeps the resolver's own reason), or
one of three specific to this kind:
"messaging-placeholder"— the entry declares placeholders, or its text contains braces the resolver would rewrite."messaging-blank"— the resolved text is empty after trimming."messaging-ladder-order"—countCopyWordsdoes not strictly increase within a ladder (one-liner, elevator, paragraph; then short, medium, long). It is reported on the later field. The two ladders are not compared with each other, and there are no other word targets.
The placeholder-value reasons of resolveCopyRef ("missing-placeholder-value"
and "unexpected-placeholder-value") never surface from this function: a kit
entry is literal text, so every one of them is reported as
"messaging-placeholder", once per field.
This function judges approval and shape, not wording. For the words of a pitch
or boilerplate, run checkCopy over each resolved text, and run
auditClaimsRegister over the claims register that backs it.
An FAQ and a tagline are out of scope (site.tagline already exists).
Front-door copy (front-door.*)
The words on a sign-in page and the pages around it are a reserved copy kind
too, so each page does not carry its own. An id is
front-door.<state>.<slot>, where the slot is one of title, description,
label, primary, secondary, notice or alt. This version ships 63
ids, listed in FRONT_DOOR_COPY_IDS (typed as FrontDoorKey), as
FRONT_DOOR_COPY_EN, a CopyRegistry with the id front-door, locale en
and revision 1; the same registry ships as data in
@clossys/writer/front-door.en.json. They are US English and cover these
states: sign-in, password, code, reset, activation, request-access,
forgot-password, identifier-not-found, the field notices (identifier-required,
password-required, code-required, network), the status notices (unavailable,
rate-limited, locked, expired, signed-out), the weak-password and
name-required notices, and the pages error, not-found,
not-authorized, access-pending and service-unavailable, plus an internal-note
label:
| Id | Text |
| --- | --- |
| front-door.sign-in.title | Sign in |
| front-door.sign-in.description | Continue to {surface}. |
| front-door.sign-in.label | Email |
| front-door.sign-in.primary | Continue |
| front-door.password.title | Enter your password |
| front-door.password.description | Signing in as {identifier}. |
| front-door.password.label | Password |
| front-door.password.primary | Sign in |
| front-door.password.secondary | Use a different email |
| front-door.identifier-not-found.notice | We couldn’t find an account for that email. Check it and try again. |
| front-door.forgot-password.label | Forgot password? |
| front-door.password.notice | That password isn’t right. Try again or reset it. |
| front-door.sign-in.alt | Sign in to {brand} |
| front-door.code.title | Check your email |
| front-door.code.description | Enter the code sent to {identifier}. |
| front-door.code.label | Code |
| front-door.code.primary | Verify |
| front-door.code.secondary | Send a new code |
| front-door.code.notice | That code isn’t right or has expired. Try again or send a new code. |
| front-door.unavailable.notice | Sign-in isn’t available right now. Try again in a few minutes. |
| front-door.rate-limited.notice | Too many attempts. Wait a few minutes, then try again. |
| front-door.locked.notice | This account is temporarily locked after too many attempts. Try again later. |
| front-door.expired.notice | Your session expired. Sign in again to continue. |
| front-door.signed-out.notice | You’re signed out. |
| front-door.error.title | This page didn’t load |
| front-door.error.description | Something went wrong. Error: {digest}. |
| front-door.error.primary | Try again |
| front-door.not-found.title | Page not found |
| front-door.not-found.description | This page doesn’t exist or has moved. |
| front-door.not-found.primary | Go to {surface} |
| front-door.not-authorized.title | You don’t have access |
| front-door.not-authorized.description | {identifier} doesn’t have access to {surface}. |
| front-door.not-authorized.primary | Switch account |
| front-door.not-authorized.secondary | Sign out |
| front-door.access-pending.title | Access pending |
| front-door.access-pending.description | Your access to {surface} awaits approval. Check back later. |
| front-door.access-pending.primary | Sign out |
| front-door.service-unavailable.title | {surface} isn’t available |
| front-door.service-unavailable.description | This is temporary. Try again soon. |
| front-door.service-unavailable.primary | Try again |
| front-door.request-access.description | Don’t have an account? |
| front-door.request-access.label | {requestAccessLabel} |
| front-door.reset.title | Reset your password |
| front-door.reset.description | Enter your email and we’ll send you a code. |
| front-door.reset.label | New password |
| front-door.reset.primary | Send code |
| front-door.reset.secondary | Back to sign in |
| front-door.reset.notice | Password updated. Sign in with your new password. |
| front-door.activation.title | Set up your account |
| front-door.activation.description | You’re invited to {surface}. Choose a password to finish. |
| front-door.activation.primary | Activate account |
| front-door.activation.notice | This invitation has expired or was already used. Ask for a new invitation. |
| front-door.internal-note.label | Internal |
| front-door.identifier-required.notice | Enter your email. |
| front-door.password-required.notice | Enter your password. |
| front-door.code-required.notice | Enter the code. |
| front-door.network.notice | Couldn’t reach {surface}. Check your connection and try again. |
| front-door.password-weak.notice | That password isn’t strong enough. Use a longer one you haven’t used anywhere else. |
| front-door.reset-code.primary | Reset password |
| front-door.activation.label | Password |
| front-door.activation-first-name.label | First name |
| front-door.activation-last-name.label | Last name |
| front-door.name-required.notice | Enter your first and last name. |
A {token} in a text is a noun from the closed set FRONT_DOOR_NOUNS:
brand, surface, identifier, digest and requestAccessLabel (typed as
FrontDoorNoun; a caller's values are FrontDoorNouns). The defaults are
marked approved and carry no approval record: that is the package's own
statement about shipped defaults, not a consumer's sign-off.
resolveFrontDoorCopy(key, nouns) resolves one entry through
resolveCopyRef and never throws. It hands the resolver only the nouns that
entry declares, so a known noun the entry does not use is dropped. The result
is { complete, text?, resolution?, issues }; text and resolution exist
only when issues is empty. An issue's reason is one of:
"unknown-copy-id"— the id is not inFRONT_DOOR_COPY_IDS."missing-noun"— a noun the entry declares is absent or blank."unknown-noun"— a noun name outsideFRONT_DOOR_NOUNS.
import { resolveFrontDoorCopy } from "@clossys/writer";
const heading = resolveFrontDoorCopy("front-door.password.title", {});
const line = resolveFrontDoorCopy("front-door.password.description", {
identifier: "[email protected]",
brand: "Acme",
});
if (line.complete) {
console.log(heading.text, line.text); // "Enter your password" "Signing in as [email protected]."
} else {
for (const issue of line.issues) console.error(issue.reason, issue.noun, issue.message);
}A site overrides one entry by registering the same id in its own registry and
resolving it with resolveCopyRef; the defaults are the fallback, not a lock.
Other states and a JSON file of the catalog are not in this version.
Delegated approval — who approved this copy, and is it still that text?
status: "approved" says an entry may render. It does not say who decided
that, when, or whether the sentence has since changed underneath the
decision. CopyRegistryEntry.approval (CopyApproval) answers all three.
writer-check approve writes the record, so nobody has to hand-edit it into
a registry or into code; writer-check approval-state reports approval state
set in source instead. textFingerprint is a content hash rather than a
human-maintained counter for the same reason fingerprint.ts gives for
translation provenance.
{
"id": "site.home.title",
"text": "Home title copy.",
"context": "home hero",
"status": "approved",
"approval": {
"approvedBy": "delegate",
"approvedAt": "2026-09-27T00:00:00.000Z",
"textFingerprint": "bb46f6c7794911342f760f6e9e46f1f6ca31d0d306c9b301a1069f4b6ff6966c",
"fingerprintAlgorithm": "sha256",
"delegate": { "id": "delegate-a", "scope": ["site.home"] },
"pendingOwnerReview": true,
"expiresAt": "2026-10-27T00:00:00.000Z"
}
}(textFingerprint above is computeCopyFingerprint("Home title copy.") —
a 64-character sha256 hex digest of that entry's own text, computed by
writer-check approve.)
approvedBy(CopyApprover,"owner" | "delegate") — who recorded this approval.textFingerprint/fingerprintAlgorithm— pin the record to the EXACT text that was approved. The momenttextchanges, a freshcomputeCopyFingerprint(entry.text)no longer matches, and the record is"approval-stale"— an error, not a warning — until it is approved again. This is the same content-derived-over-hand-maintained argumentfingerprint.tsalready makes for translation staleness, applied to approval staleness instead.delegate(CopyDelegateScope,{ id, scope }) — required whenapprovedByis"delegate", forbidden when it is"owner".idis an opaque identifier, never a personal name.scopeis one or more dot-separated entry-id namespaces the delegate may approve: an entry is in scope when its id equals a scope item or starts withitem + "."—["site.home"]coverssite.homeandsite.home.title, neversite.homepage.title.isEntryInDelegateScope(entryId, scope)is the exported predicate.pendingOwnerReview— required (and only meaningful) on a delegate record. A delegate's approval is enough for the entry to resolve — seetarget/acceptDelegateInProductionabove — but it is a warning ("approval-pending-owner-review"), not an error, until an owner durably confirms it. Approving the SAME entry again with--by ownerREPLACES the delegate's record with the owner's own — that replacement, not a separate "review" action, is how a pending delegate approval is durably confirmed.expiresAt— delegate records only, an ISO 8601 UTC timestamp strictly afterapprovedAt. Oncenowreaches it, the record is"approval-expired"— an error, mutually exclusive with"approval-stale"(staleness, the more fundamental problem, takes priority when a record is somehow both).
Production authority from an approved plan
acceptDelegateInProduction: true is a bare flag: it records no reason. The
other way to let a delegate-approved entry resolve on "production" is
approvalPlan, the bytes of an Advisor plan record whose own approval covers
the delegation. The plan declares it in an optional top-level member,
delegatedCopyApproval ({ "target": "production", "scopes": ["site.home"] }),
defined by the plan contract and its
digest definition;
the contract
and the digest page live in the public repository, not shipped in this
package. scopes is optional; when present it is a non-empty list of copy
entry-id namespaces (site.home, not site.home.*). Writer reads no file:
the caller passes the bytes.
import { createCopyResolver, planDelegateCopyAuthority, type CopyRegistry } from "@clossys/writer";
declare const registry: CopyRegistry;
declare const planBytes: Uint8Array; // the plan file's bytes, read by the caller
const authority = planDelegateCopyAuthority(planBytes);
if (!authority.authorized) throw new Error(`plan does not authorize delegate copy: ${authority.refusal}`);
const resolved = createCopyResolver(registry, { target: "production", approvalPlan: planBytes })({ id: "home.title" });
const digest = resolved?.approval?.approvedBy === "delegate" ? resolved.approval.authorizingPlanDigest : undefined;A delegate-approved entry resolves on "production" under approvalPlan when
Writer itself has established each of these from the bytes it was given:
- the bytes are strict JSON: valid UTF-8, no byte-order mark, and no key repeated at any depth;
- the plan they encode passes the plan contract's schema and its code rules
R1 to R12 and, for
delegatedCopyApproval, the schema's own checks (a non-emptyscopeslist of well-formed namespaces, a knowntarget, no unknown key). Repeating a scope item is accepted and only redundant; - the plan declares
delegatedCopyApproval; - the plan's latest decision by time has chosen
"approved"and names asubjectDigestequal to the canonical digest Writer computed from those same bytes. Decisions that tie at the same instant must all approve and name one digest; a decision time that does not parse authorizes nothing. The digest coversdelegatedCopyApprovaland itsscopes, so adding, removing or editing either after an approval leaves the plan unapproved for this purpose until a new approval names the new digest; - the entry's id is inside the declared
scopes, when scopes are declared. An entry is inside a scope when its id equals a scope item or starts withitem + ".", as for a delegate's own scope:site.homecoverssite.homeandsite.home.title, notsite.homepage.title. Withoutscopesthe declaration covers every entry. The entry's ownapproval.delegate.scopeis enforced as before, so both lists must hold.
planDelegateCopyAuthority(bytes) is the pure function that does this. It
returns a PlanDelegateCopyAuthority: { authorized: true, planDigest,
scopes? } (scopes present only when the plan declares them) or
{ authorized: false, refusal, violations? }, where refusal is a
PlanDelegateCopyRefusal and violations (only with "plan-invalid") lists
each failed rule and position, never a value. Nothing in the result quotes the
plan. The refusal codes:
"plan-not-bytes"— the argument is not aUint8Array(a string, a parsed object, aDataViewand aUint16Arrayare all refused)."plan-unreadable"— the bytes are not strict JSON (invalid UTF-8, a byte-order mark, a syntax error, or a repeated key)."plan-invalid"— the plan fails the contract's schema or code rules, which includes adelegatedCopyApprovalthat is malformed, has an emptyscopeslist or an unknowntarget, and a decision time that does not parse."delegated-copy-approval-absent"— the plan does not declaredelegatedCopyApproval."no-decisions"— the plan has no decisions."decision-time-unparseable"— a decision's time does not parse (a valid plan cannot reach this; it is kept as a second check)."latest-decision-not-approved"— a decision at the latest time has not chosen"approved", so a later deferral or rejection withdraws the grant."approval-without-subject-digest"— an approving decision at the latest time names no well-formedsubjectDigest, so it binds no bytes."latest-decisions-disagree"— decisions tied at the latest time name different digests."subject-digest-mismatch"— the latest approval names a digest other than this plan's own: the plan changed after it, or the approval belongs to another plan.
resolveCopyRef adds one more code of its own, in CopyResolvePlanRefusal
(PlanDelegateCopyRefusal plus this value): "entry-outside-plan-scopes" —
the plan authorizes delegate copy but its scopes do not include the entry.
What the resolution reports. CopyResolution.approval is a
CopyResolutionApproval: { approvedBy: "owner", pendingOwnerReview: false }
or { approvedBy: "delegate", pendingOwnerReview, authorizingPlanDigest? }.
authorizingPlanDigest is the plan's canonical digest and is present only when
an approvalPlan authorized the entry on "production". It is absent when
acceptDelegateInProduction authorized it and on "preview", so a
plan-authorized resolution can be told from a flag-authorized one by whether
the key is a string.
Precedence. An entry's own "approval-stale" and "approval-expired"
outrank every plan outcome. acceptDelegateInProduction: true together with
approvalPlan is "invalid-options", so two authorities never compete. On
"preview" the plan is accepted and ignored, and no digest is reported; the
plan is likewise unread for an owner-approved entry or one with no approval
record. It is evaluated on each call and not cached.
What this does not prove.
- Who wrote the decision. Nothing in the file shows that the latest decision is genuine; that depends on where the plan file is committed and who could commit it. Anyone able to write the file can append an approval naming the digest.
- Where the bytes came from. The plan is caller-supplied bytes, and Writer
verifies no provenance. A caller that controls the call could equally pass
acceptDelegateInProduction: true; the plan route adds a recorded, digest-bound reason, not a boundary against the caller. - Any link between the registry and the plan. The plan names no registry, revision or entry fingerprint, so it covers delegate approvals in scope that were recorded before or after the plan's approval. Entry-level staleness and expiry still apply.
- Expiry. The plan's approval has none: it holds until the file changes or a later decision withdraws it.
- More than one subject. One latest decision binds one subject, so a decision naming an apply-bundle digest grants nothing here.
- A narrow grant. A
delegatedCopyApprovalwithoutscopescovers every entry; the grant is visible only as the missing member.
writer-check approve — write or revoke a record
writer-check approve <registry-file> <entry-id>... --by owner
writer-check approve <registry-file> <entry-id>... --by delegate --delegate <id> --scope <ns>[,<ns>...] [--expires <ISO>]
writer-check approve <registry-file> <entry-id>... --revokeEvery field the command writes is either computed (approvedAt from the
clock, textFingerprint from the entry's current text) or copied from an
argument the command validated. Approving sets status: "approved"; a
delegate record is written with pendingOwnerReview: true. Multiple entry
ids are all-or-nothing: if any named id is unknown, retired, or (for a
delegate approval) outside --scope, every problem is printed and nothing
is written, including for the ids that would otherwise have succeeded. The
command leaves every other field, the key order and revision as they
were, and writes 2-space JSON to a temp file in the same directory before
renaming it into place. --revoke returns every named entry to "draft"
and removes its approval record.
Exit codes: 0 written (every named entry approved or revoked, the file on
disk now reflects it), 1 refused (an unknown, retired, or out-of-scope
entry id was named; nothing written), 2 could not run (bad arguments, or
the registry file is missing/unreadable/not valid JSON/fails schema
validation; nothing written).
writer-check approval-state — is the registry current, and does source respect it?
writer-check approval-state <registry-file> <scan-dir> [--extensions <ext>[,<ext>...]] [--format json] [--now <ISO>]Reports approval state from two independent angles in one run, because either can fail without the other noticing:
- Registry findings (
assessCopyApprovals, above):"approval-stale"and"approval-expired"are errors; a missing record ("approval-record-missing") or a delegate record stillpendingOwnerReview("approval-pending-owner-review") is a warning — legitimately approved for now, just not yet durably so. - Source findings (
scanApprovalBypass/checkApprovalBypass,approval-bypass.ts): does consumer code inscan-dirroute copy through the resolver, or around it?"approval-set-in-code"— the code itself declares approval (status: "approved", anapprovedBy/pendingOwnerReviewproperty or member assignment) — and"copy-read-without-resolver"— the code imports the registry file and reads its content some way other than passing it whole tocreateCopyResolver/resolveCopyRef/validateCopyRegistryShape, or callingparseCopyRegistryonly when its return value is passed tocreateCopyResolverorresolveCopyRef— are both errors. An unresolvable registry load (a dynamicimport()/require(), an unboundrequire, a re-export) or a string that merely names the registry file is reported asunchecked, not a finding: an incomplete picture, never assumed clean.
The coupled-file limit, stated plainly: only files that import
@clossys/writer (any subpath) or import the registry file itself are
examined. A file that copies registry data without importing either —
pasted JSON, data fetched at runtime, a registry handed in from an
uncoupled module — is not examined by this gate. Other known gaps: specifier
matching is exact (an extension-less or aliased specifier is not
recognised) and the registry-file match is by basename, which is broad
enough that a distinctive registry filename (not index.json) is worth
choosing; there is no scope tracking, so a local variable that happens to
share a flagged binding's name is flagged too; a type assertion or satisfies
between the registry binding and the closing ) of an allowed resolver call
is refused (for example createCopyResolver(registry as CopyRegistry) is not
a pass — the resolver accepts unknown, so no cast is needed); and only a directly written "approved" string
literal is caught — a value built at runtime is not.
Verdict precedence: the same "a violation outranks an incomplete
picture" ternary checkAddressability and checkPassageComposition both
use. "violated" whenever at least one error-severity finding exists
(registry or source), regardless of what is unchecked; "indeterminate"
only with zero error findings and something left unchecked, unparseable, or
zero files scanned; "satisfied" otherwise. Warnings are reported but do
not change the verdict.
Exit codes: 1 any error-severity finding (a registry error or any
approval-bypass finding); else 2 any unchecked registry load/reference,
any file that failed to parse, or zero files scanned; else 0. Warnings
never change the exit code.
--format json prints exactly one object to stdout:
{
verdict: "violated" | "indeterminate" | "satisfied",
findings: Array<
| { source: "registry", rule, severity, entryId, message }
| { source: "source", rule, severity, file, line, message }
>,
unchecked: Array<{ file, line, kind, detail }>,
counts: { errors, warnings, unchecked, filesScanned, coupledFiles }
}Where this package sits on i18n
"i18n" is two different things, and this package deliberately does only one of them.
Translation RUNTIME — ICU message format, plural rules, locale
negotiation, date/number formatting — is out of scope. CopyLocale is a
plain string (see types.ts) precisely so this package never grows a second
internationalisation stack: that job already belongs to Intl
(Intl.PluralRules, Intl.ListFormat, Intl.RelativeTimeFormat,
Intl.NumberFormat) and a consumer's own choice of locale-negotiation
policy. This is a scope decision, not a gap this package failed to fill —
see placeholders' own doc comment in types.ts for the same point made
about interpolation specifically.
Translation GOVERNANCE — is every locale actually covered, has the source locale drifted ahead of its translations, is a target locale carrying entries that no longer exist upstream — is this package's job, because it is the same "addressable, checkable copy" problem this package already solves within one locale, applied across locales. What this package DOES do for multi-locale work:
Addressing and resolution:
CopyRegistryis already locale-keyed (locale: CopyLocale), andresolveCopyRefalready fails closed with a"locale-mismatch"issue when aCopyRefrequests a locale a given registry does not provide.Coverage governance:
checkLocaleCoverage(below) checks a set of locale-keyed registries against a declared source locale for missing coverage (an entry the source has that a target locale doesn't) and orphaned entries (an entry a target locale has that the source no longer does).Staleness governance:
checkLocaleCoveragealso checks whether a target-locale entry's translation is still current against a since-edited source entry. This is deliberately content-derived, not a human-maintained revision counter:CopyRegistryEntry.translation(CopyTranslationProvenance) records asourceFingerprint— the source entry'stext, digested bycomputeCopyFingerprint(node:cryptosha256, no runtime dependency) at the moment the translation was produced.checkLocaleCoveragerecomputes that fingerprint against the source entry's CURRENTtextand compares. A hand-bumped revision number requires someone to remember to bump it every time source copy changes, and nothing enforces that discipline — it drifts silently. A content hash cannot drift: identical text always fingerprints identically, and any edit changes the fingerprint with certainty.translationis optional — an entry authored before this field existed, or by a host that has not adopted it, remains a fully validCopyRegistryEntry.checkLocaleCoveragetreats "checked, still current" (no finding), "checked, and stale" ("locale-coverage:stale-entry"), and "no provenance recorded, cannot tell" ("locale-coverage:provenance-missing") as three genuinely different outcomes, never collapsed into one signal — collapsing "cannot tell" into either of the other two would silently report a dimension it did not actually check as either clean or dirty.Interpolation-parity governance: for every entry present in both a source and target locale,
checkLocaleCoveragealso compares each entry'splaceholdersin both directions: a name the source declares that the target's translation is missing ("locale-coverage:interpolation-missing", a broken sentence at render time — a required value has nowhere to interpolate into) and a name the target declares that the source never did ("locale-coverage:interpolation-extra", an unfilled{name}token rendered straight to a user). Both are"error"severity, matching the same-class-of-bug precedentplaceholder-missing-from-textalready sets within one locale.See
locale-coverage.ts's doc comment for the full design, including why this stays translation governance, never a competing translation runtime — the boundary below is unchanged by any of this.
writer-check locale-coverage <registries-file> <source-locale>
[declared-locale...] (see cli.ts — a THIRD subcommand of the existing
writer-check bin, dispatched on an explicit argv[0] === "locale-coverage",
never by installed bin name or invoking path, the same dispatch shape
cli.ts already uses for its voice-derivation-coverage and
addressability subcommands) runs checkLocaleCoverage from the command
line. registries-file is a JSON file: a plain object mapping each
locale to its CopyRegistry ({"en": {...}, "fr": {...}}).
declared-locale defaults to every key registries-file itself declares,
in file order, when omitted; pass it explicitly to also assert that some
OTHER locale — declared, but with no registry present in the file at
all — is missing. Exit code is the exact mapping
LocaleCoverageReport.complete's own doc comment states as its caller
contract: 0 when every declared locale was evaluated and no
"error"-severity finding was produced (a "warning"-only run — orphaned
entries, stale translations, missing provenance — still exits 0), 1
when every declared locale was evaluated but at least one "error"-severity
finding was produced, 2 when any declared locale was NOT actually
evaluated (bad input, an unreadable/unparseable/non-object registries-file,
zero declared locales, or a missing/invalid/empty source locale).
Voice glossary vs. i18n glossary — two different axes, easy to conflate
@clossys/writer/voice's GlossaryEntry (term/status/reason/
alternative/caseSensitive) is a voice glossary: it enforces brand
terms within one locale — "never say utilize, say use," checked by
checkCopy against one string in one language. It has no concept of a
second locale at all: a VoiceRecord is documented as "one consumer's
complete, bound voice," singular, and checkCopy never receives a locale
argument.
An i18n glossary is a different axis over similar-looking machinery: it
enforces that a term stays equivalent across locales — that whatever "an
en entry translates to in fr" actually says the same thing, not that either
locale's copy avoids a forbidden word. That requires a locale-keyed
term-registry shape (multiple per-locale phrases grouped under one
term-equivalence id) that GlossaryEntry/VoiceRecord do not have and were
never designed to grow: VoiceRecord is deliberately a single, monolingual,
bound voice, and threading a locale axis through checkCopy's single-string
contract would distort what that function already promises, not extend it.
This package does not ship an i18n glossary for that reason — implementing
one honestly is a new register (structurally closer to checkLocaleCoverage
above, generalized from entry ids to term-equivalence ids) rather than a
small addition to copy/voice.
Voice rule vocabulary: patterns, severity, and channels
Three additions to copy/voice's rule model, all strictly additive: an
existing VoiceRecord that uses none of them validates and behaves exactly
as it did before (see "Scope discipline" below).
Pattern rules vs. the glossary
GlossaryEntry matches one literal term. It cannot express alternation
("deep dive" vs. "dive deep"), an optional apostrophe ("it's" vs.
"its"), or a hard ban on a specific character (a U+2014 em dash). A
PatternRule closes all three gaps with one mechanism — a regex — because a
punctuation ban is just a pattern with no alternation in it:
import { checkCopy, type VoiceRecord } from "@clossys/writer/voice";
const voice: VoiceRecord = {
id: "acme-app",
rules: { /* ... */ } as VoiceRecord["rules"],
glossary: [],
claims: [],
patterns: [
{
id: "no-em-dash",
description: "hard ban on the em dash",
pattern: { source: "\\u2014" },
severity: "error",
reason: "house style bans the em dash — use a comma or period",
},
{
id: "deep-dive",
description: "banned buzzword, either word order",
pattern: { source: "\\b(deep dive|dive deep)\\b", flags: "i" },
severity: "warning",
reason: "overused",
alternative: "look closely at",
},
],
};
checkCopy(voice, "Let's take a deep dive into this — with an em dash.");Regex safety. A caller-supplied pattern can hang a scanner via
catastrophic backtracking. This package's position: bound the pattern AT
REGISTRATION TIME, never at run time — there is no runtime timeout anywhere
in this package. checkPatternSafety (exported, so a consumer can validate
a pattern before it ever reaches a VoiceRecord) rejects, as a real
finding, never a silent skip: a disallowed flag (only i/u/s are
accepted — never g/y/m), a source over 200 characters, a
backreference (\1, \k<name>), a bounded quantifier whose upper bound
exceeds 50, and — the classic catastrophic-backtracking shape — a
quantifier applied to a group that itself contains an unbounded quantifier
((a+)+, (a*)*, (.*)+). This is a real, bounded, documented static
gate, not a general ReDoS detector: it does not catch overlapping-alternation
blowup with no nested quantifier ((a|a)+) — see
src/voice/internal/pattern-safety.ts's top doc comment for the complete,
honest limitation list.
An invalid pattern is a finding, not a silent skip. Both
validateVoiceRecordShape/parseVoiceRecord (at registration) and
checkCopy itself (as defense-in-depth, since checkCopy does not trust
that its VoiceRecord argument was ever validated) run this same gate. A
pattern that fails compiles to a "pattern:invalid-rule" error finding
— always "error", regardless of the rule's own declared severity, and,
like "voice:unbound-placeholder", it can never be waived away. A rule the
author believes is enforcing something must never silently stop enforcing
it.
Patterns are serialized as { source, flags } — the two RegExp
constructor arguments — never a real RegExp instance, because a
VoiceRecord is checked-in JSON data, not code.
Three severity tiers, and what each means for CI
VoiceFinding.severity is now "error" | "warning" | "advisory" (widened
from "error" | "warning" — every existing finding this package produces
still uses exactly the same value it always did):
| Tier | Meaning |
| --- | --- |
| "error" | Fails CI. This package's documented idiom — report.findings.some(f => f.severity === "error") — treats this, and only this, tier as build-breaking. isCiBlockingSeverity(severity) is the same check, exported so a caller does not have to hardcode the string. |
| "warning" | Fails only a narrower, editorial gate — a stricter, consumer-owned check (e.g. "block merge to a marketing branch") that this package does not implement. Unchanged in behavior from before this release: the tense dimension already produced "warning" findings, and nothing about how they flow through checkCopy has changed. |
| "advisory" | Purely informational. Never fails anything, including the narrower editorial gate. |
Only PatternRule.severity requires an author to pick one of these
explicitly — every other dimension's severity remains hardcoded by
checker.ts, exactly as before.
Channel scoping
An optional channel on a GlossaryEntry or PatternRule scopes it to one
named channel — LinkedIn, X, HN, or whatever a consumer's own product calls
its channels. This package does not define what a channel is. VoiceChannel
is a plain string, validated for shape only (non-empty, non-whitespace),
exactly the same seam CopyLocale draws for locale and Claim.factRef
draws for a facts registry:
{
term: "synergy",
status: "forbidden",
reason: "buzzword",
caseSensitive: false,
channel: "linkedin", // this rule only applies when checkCopy is called with { channel: "linkedin" }
}checkCopy(voice, copyForLinkedIn, { channel: "linkedin" });A rule with no channel is global and always applies. A channel-scoped rule
applies only when options.channel matches it EXACTLY (plain string
equality — no case-folding, no interpretation). Omitting options.channel
entirely is identical to every release before this one: no rule has a
channel unless its author added one.
Scope discipline
patterns is optional at the VoiceRecord TYPE level (not merely defaulted
at validation time, the way glossary/claims are) specifically so every
VoiceRecord object literal written before this feature existed keeps
compiling and behaving unchanged: a record that never declares patterns
gets no "pattern" entry in checkCopy's ran/skipped at all — not
"skipped for lack of configuration", genuinely absent, so
report.complete/report.skipped.length reproduce this package's
pre-pattern-rule behavior exactly. packages/writer/src/voice/checker.test.ts
pins this explicitly.
Path exclusions for the scanning surface
The mention-vs-use failure: a style guide that documents this voice's own
banned terms — "never say X, say Y instead" — necessarily contains the
literal banned text, as a MENTION, not a USE. Scanned like any other file,
that mention looks identical to real, unregistered product copy.
ScanOptions.pathExclusions fixes this for scanCopySourceTree's scanning
surface (the walk that feeds checkCopyTraceability):
import { scanCopySourceTree } from "@clossys/writer";
const scan = scanCopySourceTree(sourceDir, {
pathExclusions: [
{ path: "docs/style-guide.ts", reason: "documents banned terms; does not ship them" },
{ path: "docs/**", reason: "internal documentation, not product copy" },
{ path: "fixtures/*.ts", reason: "test fixtures" },
],
});A matched file is skipped BEFORE it is ever tokenized — it contributes no
candidates, no excluded literals, no citations, no unchecked items, exactly
as if it did not exist. The pattern language is deliberately small (no glob
library, this package's usual zero-runtime-dependency rule): an exact path,
a dir/** subtree, or a single * confined to the final path segment.
This is a different feature from ExclusionReason/ExcludedLiteral,
which scan.ts already had: that mechanism classifies one LITERAL already
found inside a file that IS being scanned (is this string an import
specifier, a CSS class, ...?) — a per-literal judgment. pathExclusions
answers a different question at a different granularity: should this FILE
be looked at at all, before a single character of it is tokenized? See
src/path-exclusions.ts's top doc comment for the full argument for why
these are two separate mechanisms, not a coincidence of both being named
"exclusion".
Fails closed, mirroring checkCopy's VoiceCheckWaiver handling: a
malformed entry (missing/empty path or reason, or a pattern this small
grammar cannot parse) is never applied — it exempts nothing — and is
reported as an "error"-severity PathExclusionFinding on
ScanResult.pathExclusionFindings. An exclusion that matched zero files
this run is reported too, as a "warning", since a stale exclusion (the
file it named was renamed or deleted) is otherwise indistinguishable from
one still doing real work.
Machine-readable output: writer-check --format json
The default writer-check command takes --format <text|json>, defaulting to
text. Under json it prints exactly one object to stdout and nothing else —
every diagnostic that would normally go to stdout is suppressed, so the output
is always parseable — and writes nothing to stderr on a successful run.
The contract that matters is what the object carries, and when:
{
recordFile, scanDir,
verdict: "clean" | "findings" | "indeterminate",
exitCode: 0 | 1 | 2,
reason?, // present when the verdict needs explaining
filesScanned, candidatesScanned, matched,
findings: CopyGateFinding[],
ignored: CopyGateIgnored[],
unchecked: UncheckedItem[],
parseFailures: { file, detail }[]
}verdict, findings and unchecked are always present together, on every
path — the clean path, the findings path, and the total-failure path alike.
That is the whole point of the shape. A single construct the scanner cannot
classify makes the run indeterminate and exits 2, but the findings it did
produce are still in findings, recoverable by any caller that reads past the
exit code.
That mattered concretely: before this shape existed, one unclassifiable JSX
comment between attributes forced exit 2 and discarded 292 real findings
from the same scan (issue #753). The exit code and the verdict still refuse to
read as clean — an indeterminate must never look like a pass — but "I could
not evaluate THIS ITEM" is no longer collapsed into "I could not evaluate
ANYTHING."
A caller keying only on exitCode or verdict therefore never mistakes an
indeterminate run for a clean one; a caller that reads findings recovers
everything the run measured regardless of how it ended.
This mirrors @clossys/inspector's VerifyStandardsReport (per-row verdict
plus one overall verdict and exit code) and @clossys/observer's
CoverageCellState three-state union, rather than inventing a fourth
convention.
Copy addressability — is prose resolved from the registry, or typed inline?
checkCopyTraceability above answers "does this literal match a registered
entry, or carry a copy:<id> citation?" — but a literal that matches is
still a literal: the sentence sits in the component's own source, so a
marketing rename means editing every component that typed it, not one
registry entry. checkAddressability (addressability.ts) answers the
stricter question traceability does not: is this prose actually resolved
from the registry by id? There is no citation or text-match escape hatch.
import { scanAddressabilitySources, checkAddressability } from "@clossys/writer";
const scan = scanAddressabilitySources(sourceDir);
const result = checkAddressability(scan);
// result.verdict: "satisfied" | "violated" | "indeterminate"Four positions are classified:
- Markup text nodes (
<span>Hello</span>'sHello) — always a violation when it carries real prose. - The four user-facing attributes —
aria-label,placeholder,alt,title— carry prose a person reads and are NOT text nodes; a scanner that only understands text nodes reports zero on a component whose entire user-facing surface is<input aria-label="..." />. A violation when the value is literal prose ON A JSX ELEMENT — but NOT when the same shape is actually a destructuring-pattern default or a plain parameter default ({ "aria-label": ariaLabel = "Pagination" },{ placeholder = "Search" },function f(alt = "...")): the consumer can override a default, so the component's own source does not lock the sentence in the way a hardcoded JSX attribute does, and flagging one would invert the verdict on an already-addressable construct. - Copy-bearing object-literal values — chrome config bags
(
{ label: "Request access", href: "/join" },items[].label, nav link tables) are Writer surfaces. A literal on keys such aslabel,title,cta,caption,heading,kicker,body,description, oraria-labelis a violation with file and key path. Allowlisted keys (href,to,path,icon, …) and route-shaped values (/pricing) are not copy — aSiteHeaderlabels array is not an escape hatch. - Everything else — a template literal (in any position, including
one of the four attributes above), or a prop that is none of the four —
this gate cannot confidently tell
whether it is resolved-through-an-id or genuinely non-user-facing, so it
is reported as
unchecked(indeterminate), never silently treated as clean — UNLESS the string is itself shaped like a CSS/Tailwind utility class list ("border-t border-line-base pt-xs"), which is definitively not prose and is excluded outright, the same wayscan.tsalready excludes that shape when it has an actualclassNameattribute name to key off.
Verdict precedence (changed in 0.2.0 — see issue #407).
AddressabilityGateResult.verdict is "violated" whenever at least one
violation was found, REGARDLESS of how many string positions are
unclassified. It is "indeterminate" only when there are zero violations
AND unchecked is non-empty, zero components were scanned, or the tree
could not be read. This is deliberately the OPPOSITE of
checkCopyTraceability's "a 2 gates before findings are counted"
precedence: on a real tree, hundreds of string positions are token data this
gate cannot classify by design, so letting unclassified-count outrank a
violation made "violated" unreachable outside a fixture — every real run
had some unclassified positions, so it always read 2. A caller that had
learned to treat that 2 as "coverage is never complete, ignore it" would
never see the violations a run actually found. The coverage gap itself is
still fully reported: unchecked/reasons stay populated and
writer-check addressability's own accounting output still prints them
unconditionally, independent of verdict — only the verdict (and the exit
code a 1 maps to) changed.
Before 0.2.0, a caller that treated exit 2 from writer-check
addressability as "flaky, coverage is never complete, ignore it" will now
see exit 1 on trees that used to report 2 — that is the fix, not a
regression: those trees had a real violation the old precedence was hiding
behind a coverage-gap exit code.
writer-check addressability [scan-dir] (see cli.ts — a subcommand of the
existing writer-check bin, dispatched on an explicit argv[0] ===
"addressability", never by installed bin name or invoking path) exits
0 clean / 1 at least one violation (regardless of unclassified count) /
2 zero violations but could not fully evaluate — deliberately a SEPARATE
exit code from writer-check's default command rather than folded into it,
since the two gates' natural test fixtures are structurally incompatible (a
literal traceability needs to prove a registry match is exactly a literal
addressability cannot confirm is safe).
Options:
--extensions <ext>(repeatable, and each occurrence may be a comma-separated list) — file extensions to scan, each including the leading dot (for example.mjs, or.mjs,.cjsin one flag). Values union across both repeats and comma-separated entries. When omitted, the default is.ts,.tsx,.js, and.jsx. When any--extensionsflag is present, that default set is replaced. Every value must include the leading dot, and a flag that resolves to zero extensions (a bare comma, or an empty string) is a usage error (exit2) — it never falls back to the default set, which would be a vacuous pass on an explicit-but-empty request.--chrome <file>(repeatable) — persistent chrome (site header, footer, skip link, nav labels) shell or layout file to scan in addition toscan-dir. Paths are relative toscan-dirunless absolute. Each file must exist.--require-chrome— refuse to run (exit2) when no--chromefile was declared. Use when the surface mounts persistent chrome outsidescan-dir.
This supplier repository does not ship a copy-registry subject for
addressability to run against. The writer-check CLI strings in this tree
are inline operator messaging on purpose, not product UI copy. Consumers
run addressability on their own product UI source trees, not on this
package's own CLI sources.
The passage layer — the missing middle between an entry and a document
@example/copy (now writer) has terms (a glossary) and entries
(single addressable strings) and nothing between them. @clossys/designer
has tokens, atoms, and blocks. In practice nobody reuses one string —
they reuse a whole empty-state (title + body + action), a whole FAQ item
(question + answer), a whole error (message + recovery). Passage
(passage.ts) is that missing middle: it composes entry/term REFERENCES
the way a block composes atoms, never a raw sentence of its own. Terms ≈
tokens, entries ≈ atoms, passages ≈ blocks; documents (a later,
composition-layer concern, mirroring how a view composes blocks) are out
of scope here — see issue #373.
import { checkPassageComposition, readPassageRecord } from "@clossys/writer";
const read = readPassageRecord("passages.json");
if (read.complete && read.record) {
const result = checkPassageComposition(read.record);
// result.verdict: "satisfied" | "violated" | "indeterminate"
}A Passage has a stable, dot-separated id, a required context (an
unlocatable passage is not reviewable — the same rule CopyEntry.context
already enforces one layer down), and fields: named slots, each of which
should hold a PassageReference — { ref: "entry", id } or { ref: "term",
term } — never a literal string, and never a pointer into another
passage's own fields ({ ref: "passage", ... }).
The gate, with the ternary:
0(satisfied) — every passage references only entries and terms, at least one passage evaluated.1(violated) — a passage inlines a literal string instead of referencing an entry (the verbal equivalent of a hardcoded value instead of a token), or references another passage's own internals. Wins over an incomplete picture in the same run — the same "a real violation must outrank an incomplete scan" precedencecheckAddressabilitysettled on for issue #407/#433, applied here from the start rather than discovered later.2(indeterminate) — the registry could not be read/parsed/validated, zero passages were registered, or a field's value could not be confidently classified, with zero violations found. Fails CLOSED with a machine-readable reason (PassageGateResult.reasons); never a silent pass.
What this gate deliberately does not do: verify a referenced entry id
or term actually EXISTS in a real CopyRecord/glossary. That is a
different, weaker question ("does this id resolve") than the one this gate
answers ("is this field a reference at all, or a smuggled-in literal") —
the same split checkAddressability already draws from
checkCopyTraceability. passage.adversarial.test.ts proves the
separation directly: a weaker tool that only checks "every referenced
entry id exists" passes a passage built entirely from inline literals
(zero references means nothing to check), while writer-check passages,
spawned as the compiled CLI exactly the way this repository invokes every
gate, correctly exits 1 on the identical fixture — plus the sanity check
that the weak tool is not simply broken (both agree when references
genuinely are valid).
Not ported: @clossys/designer/tokens' brandable boolean. In
tokens, brandable marks a subset WITHIN one namespace (154 ship, 42 are
brandable). The voice record's own consumer/machinery split runs between
FILES instead — forcing a boolean into a Passage field here would be
false symmetry with a split that does not exist at this layer. This
package mirrors the LADDER (terms/entries/passages/documents), never the
binding mechanism.
writer-check passages <registry-file> (see cli.ts — a subcommand of the
same writer-check bin, dispatched on argv[0] === "passages", the same
fully-separate dispatch addressability already uses) exits 0 satisfied
/ 1 violated / 2 indeterminate.
API
The root entry point exports the copy registry and traceability surface:
- Registry and schema:
parseCopyRecord,validateCopyRecordShape,parseCopyRegistry,validateCopyRegistryShape,readCopyRecord,createCopyResolver,resolveCopyRef,checkCopyRecord,CopyRecord,CopyRegistry,CopyEntry,CopyRegistryEntry,CopyRef,CopyResolution,CopyResolver,CopySource,CopyLocale,CopyValue,CopyEntryStatus,CopyResolveIssue,CopyResolveIssueReason,CopyResolveOptions,CopyResolveResult,CopyResolveTarget,CopyEntryId,CopyFinding,CopyRegistryReadIssue,CopyRegistryReadIssueReason,CopyRegistryReadResult,CopyEntryCheckResult,CopyEntrySkip,CopyRecordCheckOptions,CopyRecordCheckReport,CopyRecordFinding, andCopyRecordWaivedFinding. - Site identity (see above):
resolveSiteIdentity,SITE_NAME_COPY_ID,SITE_TAGLINE_COPY_ID,SITE_IDENTITY_COPY_IDS,SiteIdentityField,SiteIdentityIssue,SiteIdentityIssueReason,SiteIdentityOptions, andSiteIdentityResolution. - Messaging kit (see above):
resolveMessagingKit,MESSAGING_KIT_COPY_IDS,MESSAGING_PITCH_ONE_LINER_COPY_ID,MESSAGING_PITCH_ELEVATOR_COPY_ID,MESSAGING_PITCH_PARAGRAPH_COPY_ID,MESSAGING_BOILERPLATE_SHORT_COPY_ID, `MESS
