@clossys/writer 0.3.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +647 -6
  2. package/dist/addressability.d.ts +11 -1
  3. package/dist/addressability.d.ts.map +1 -1
  4. package/dist/addressability.js +169 -3
  5. package/dist/addressability.js.map +1 -1
  6. package/dist/approval-bypass.d.ts +120 -0
  7. package/dist/approval-bypass.d.ts.map +1 -0
  8. package/dist/approval-bypass.js +2318 -0
  9. package/dist/approval-bypass.js.map +1 -0
  10. package/dist/approval.d.ts +62 -0
  11. package/dist/approval.d.ts.map +1 -0
  12. package/dist/approval.js +421 -0
  13. package/dist/approval.js.map +1 -0
  14. package/dist/approve-cli.d.ts +82 -0
  15. package/dist/approve-cli.d.ts.map +1 -0
  16. package/dist/approve-cli.js +430 -0
  17. package/dist/approve-cli.js.map +1 -0
  18. package/dist/cli.d.ts +15 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +264 -22
  21. package/dist/cli.js.map +1 -1
  22. package/dist/field-options.d.ts +59 -0
  23. package/dist/field-options.d.ts.map +1 -0
  24. package/dist/field-options.js +109 -0
  25. package/dist/field-options.js.map +1 -0
  26. package/dist/fold-wallpaper-gate.d.ts +7 -0
  27. package/dist/fold-wallpaper-gate.d.ts.map +1 -0
  28. package/dist/fold-wallpaper-gate.js +29 -0
  29. package/dist/fold-wallpaper-gate.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 +82 -0
  35. package/dist/front-door.d.ts.map +1 -0
  36. package/dist/front-door.js +271 -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 +18 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +26 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/live-copy-gate.d.ts +16 -0
  51. package/dist/live-copy-gate.d.ts.map +1 -0
  52. package/dist/live-copy-gate.js +135 -0
  53. package/dist/live-copy-gate.js.map +1 -0
  54. package/dist/messaging-kit.d.ts +113 -0
  55. package/dist/messaging-kit.d.ts.map +1 -0
  56. package/dist/messaging-kit.js +121 -0
  57. package/dist/messaging-kit.js.map +1 -0
  58. package/dist/passage.d.ts +2 -1
  59. package/dist/passage.d.ts.map +1 -1
  60. package/dist/passage.js +2 -1
  61. package/dist/passage.js.map +1 -1
  62. package/dist/plan-authority.d.ts +73 -0
  63. package/dist/plan-authority.d.ts.map +1 -0
  64. package/dist/plan-authority.js +126 -0
  65. package/dist/plan-authority.js.map +1 -0
  66. package/dist/plan-contract.d.ts +16 -0
  67. package/dist/plan-contract.d.ts.map +1 -0
  68. package/dist/plan-contract.js +33 -0
  69. package/dist/plan-contract.js.map +1 -0
  70. package/dist/plan-digest.d.ts +23 -0
  71. package/dist/plan-digest.d.ts.map +1 -0
  72. package/dist/plan-digest.js +82 -0
  73. package/dist/plan-digest.js.map +1 -0
  74. package/dist/plan-rules.d.ts +54 -0
  75. package/dist/plan-rules.d.ts.map +1 -0
  76. package/dist/plan-rules.js +145 -0
  77. package/dist/plan-rules.js.map +1 -0
  78. package/dist/render-registry-parity.d.ts +20 -0
  79. package/dist/render-registry-parity.d.ts.map +1 -0
  80. package/dist/render-registry-parity.js +45 -0
  81. package/dist/render-registry-parity.js.map +1 -0
  82. package/dist/resolve.d.ts +40 -6
  83. package/dist/resolve.d.ts.map +1 -1
  84. package/dist/resolve.js +144 -18
  85. package/dist/resolve.js.map +1 -1
  86. package/dist/scan.d.ts +32 -0
  87. package/dist/scan.d.ts.map +1 -1
  88. package/dist/scan.js +67 -6
  89. package/dist/scan.js.map +1 -1
  90. package/dist/schema.d.ts +1 -0
  91. package/dist/schema.d.ts.map +1 -1
  92. package/dist/schema.js +231 -5
  93. package/dist/schema.js.map +1 -1
  94. package/dist/site-identity.d.ts +88 -0
  95. package/dist/site-identity.d.ts.map +1 -0
  96. package/dist/site-identity.js +82 -0
  97. package/dist/site-identity.js.map +1 -0
  98. package/dist/treatment-word-budget.d.ts +31 -0
  99. package/dist/treatment-word-budget.d.ts.map +1 -0
  100. package/dist/treatment-word-budget.js +80 -0
  101. package/dist/treatment-word-budget.js.map +1 -0
  102. package/dist/types.d.ts +76 -7
  103. package/dist/types.d.ts.map +1 -1
  104. package/dist/types.js +9 -7
  105. package/dist/types.js.map +1 -1
  106. package/fit-signals.json +11 -0
  107. package/package.json +201 -11
  108. package/skill/SKILL.md +49 -0
  109. package/src/addressability.ts +186 -4
  110. package/src/approval-bypass.ts +2263 -0
  111. package/src/approval.ts +491 -0
  112. package/src/approve-cli.ts +474 -0
  113. package/src/cli.ts +274 -22
  114. package/src/field-options.ts +134 -0
  115. package/src/fold-wallpaper-gate.ts +29 -0
  116. package/src/front-door.check.ts +10 -0
  117. package/src/front-door.ts +312 -0
  118. package/src/generated/advisor-plan-contract.generated.ts +430 -0
  119. package/src/generated/contract-schema.generated.ts +520 -0
  120. package/src/index.ts +107 -1
  121. package/src/live-copy-gate.ts +150 -0
  122. package/src/messaging-kit.ts +195 -0
  123. package/src/passage.ts +2 -1
  124. package/src/plan-authority.ts +160 -0
  125. package/src/plan-contract.ts +45 -0
  126. package/src/plan-digest.ts +81 -0
  127. package/src/plan-rules.ts +179 -0
  128. package/src/render-registry-parity.ts +65 -0
  129. package/src/resolve.ts +185 -19
  130. package/src/scan.ts +97 -6
  131. package/src/schema.ts +247 -5
  132. package/src/site-identity.ts +125 -0
  133. package/src/treatment-word-budget.ts +100 -0
  134. package/src/types.ts +83 -7
  135. package/templates/front-door.en.json +426 -0
  136. package/CHANGELOG.md +0 -330
@@ -0,0 +1,179 @@
1
+ // The plan contract's code rules R1-R12 (issue #1178, read here for issue
2
+ // #1586): the checks the contract's JSON Schema keywords cannot express,
3
+ // because each one relates one field to another. They are defined once, as
4
+ // prose in the description of the shared contract
5
+ // docs/contracts/advisor-plan.json -- in the public repository, not shipped
6
+ // in this package. This is @clossys/writer's own implementation, written from
7
+ // that text; every other reader of a plan implements the rules separately,
8
+ // and each is tested against one corpus,
9
+ // docs/contracts/advisor-plan-rules.fixture.json (likewise in the public
10
+ // repository, not shipped in this package), so a plan one of them accepts,
11
+ // they all accept, and the reverse.
12
+ //
13
+ // These functions assume the value already passed the plan contract's
14
+ // schema: plan-contract.ts runs them only then. A violation names its rule and
15
+ // the position of the field at fault, never a value, because a plan can carry
16
+ // founder text.
17
+
18
+ import { ADVISOR_PLAN_CONTRACT } from "./generated/advisor-plan-contract.generated.js";
19
+
20
+ /** A code rule of the plan contract. */
21
+ export type PlanRuleId = "R1" | "R2" | "R3" | "R4" | "R5" | "R6" | "R7" | "R8" | "R9" | "R10" | "R11" | "R12";
22
+
23
+ /**
24
+ * The roles whose package lives in the engagement hub only, read from the
25
+ * packed plan contract's `definitions.hubOnlyRoles`, the one list every
26
+ * reader of a plan uses. Code rules R2 and R11 use it. A packed contract
27
+ * without a well-formed list is a build defect, so loading this module throws
28
+ * rather than judge plans against a guess.
29
+ */
30
+ export const HUB_ONLY_ROLES: readonly string[] = (() => {
31
+ const definitions = ADVISOR_PLAN_CONTRACT.definitions as Record<string, { const?: unknown }> | undefined;
32
+ const roles = definitions?.hubOnlyRoles?.const;
33
+ if (!Array.isArray(roles) || roles.some((role) => typeof role !== "string")) {
34
+ throw new Error("the packed plan contract has no well-formed definitions.hubOnlyRoles");
35
+ }
36
+ return Object.freeze([...(roles as string[])]);
37
+ })();
38
+
39
+ /**
40
+ * The members of a plan these rules and the authority read, and no more. A
41
+ * plan that passed the contract's schema has this shape; it is a structural
42
+ * view, not the contract, and it validates nothing.
43
+ */
44
+ export interface PlanDocument {
45
+ readonly mandate: { readonly roles: readonly string[] };
46
+ readonly staffing?: readonly { readonly repository: string; readonly roles: readonly string[] }[];
47
+ readonly packages?: readonly {
48
+ readonly planItem: string;
49
+ readonly repository: string;
50
+ readonly act: string;
51
+ readonly name: string;
52
+ readonly placement: string;
53
+ }[];
54
+ readonly kits?: readonly { readonly id: string }[];
55
+ readonly resolution?: unknown;
56
+ readonly delegatedCopyApproval?: { readonly target: "production"; readonly scopes?: readonly string[] };
57
+ readonly decisions: readonly { readonly at: string; readonly chosen: string; readonly subjectDigest?: string }[];
58
+ }
59
+
60
+ export interface PlanRuleViolation {
61
+ readonly rule: PlanRuleId;
62
+ /** The field at fault, such as `staffing[1].repository`. */
63
+ readonly path: string;
64
+ /** What is wrong, by position only. */
65
+ readonly message: string;
66
+ }
67
+
68
+ /**
69
+ * The position of every item whose key an earlier item already had, with the
70
+ * position of the first item that had it. Items keep their order.
71
+ */
72
+ function repeats(keys: readonly string[]): { index: number; first: number }[] {
73
+ const seenAt = new Map<string, number>();
74
+ const found: { index: number; first: number }[] = [];
75
+ keys.forEach((key, index) => {
76
+ const first = seenAt.get(key);
77
+ if (first === undefined) seenAt.set(key, index);
78
+ else found.push({ index, first });
79
+ });
80
+ return found;
81
+ }
82
+
83
+ /**
84
+ * A plan's own member, or undefined. The schema checker sees only own
85
+ * members, so these rules read only those too: a member inherited from a
86
+ * prototype is ignored, never judged.
87
+ */
88
+ function ownMember<T extends object, K extends keyof T>(document: T, name: K): T[K] | undefined {
89
+ return Object.hasOwn(document, name) ? document[name] : undefined;
90
+ }
91
+
92
+ /** Every violation of R1-R12, for a plan that already passed the plan contract's schema. */
93
+ export function planRuleViolations(plan: PlanDocument): PlanRuleViolation[] {
94
+ const found: PlanRuleViolation[] = [];
95
+ const report = (rule: PlanRuleId, path: string, message: string): void => {
96
+ found.push({ rule, path, message });
97
+ };
98
+ const staffing = ownMember(plan, "staffing");
99
+ const acts = ownMember(plan, "packages");
100
+ const kits = ownMember(plan, "kits");
101
+ const mandateRoles = plan.mandate.roles;
102
+ const lower = (text: string): string => text.toLowerCase();
103
+
104
+ if (staffing !== undefined) {
105
+ // R1: one staffing entry per repository; ids compare case-insensitively.
106
+ for (const { index, first } of repeats(staffing.map((entry) => lower(entry.repository)))) {
107
+ report("R1", `staffing[${index}].repository`, `names the same repository as staffing[${first}].repository (repository ids compare case-insensitively)`);
108
+ }
109
+
110
+ // R2: staffed roles and mandate roles agree in both directions; a hub-only role is never required to be staffed.
111
+ const mandated = new Set(mandateRoles);
112
+ const staffed = new Set(staffing.flatMap((entry) => entry.roles));
113
+ staffing.forEach((entry, index) => {
114
+ entry.roles.forEach((role, position) => {
115
+ if (!mandated.has(role)) report("R2", `staffing[${index}].roles[${position}]`, "is not one of mandate.roles");
116
+ });
117
+ });
118
+ mandateRoles.forEach((role, position) => {
119
+ if (!staffed.has(role) && !HUB_ONLY_ROLES.includes(role)) report("R2", `mandate.roles[${position}]`, "is not staffed in any staffing entry");
120
+ });
121
+
122
+ staffing.forEach((entry, index) => {
123
+ // R8: no role twice within one staffing entry.
124
+ for (const { index: position, first } of repeats(entry.roles)) {
125
+ report("R8", `staffing[${index}].roles[${position}]`, `repeats staffing[${index}].roles[${first}]`);
126
+ }
127
+ // R11: a hub-only role is never staffed in a repository.
128
+ entry.roles.forEach((role, position) => {
129
+ if (HUB_ONLY_ROLES.includes(role)) report("R11", `staffing[${index}].roles[${position}]`, "is a hub-only role, which works from the hub and is never staffed in a repository");
130
+ });
131
+ });
132
+ }
133
+
134
+ if (acts !== undefined) {
135
+ // R3: every act names a staffed repository, spelled exactly the same.
136
+ const staffedRepositories = new Set((staffing ?? []).map((entry) => entry.repository));
137
+ acts.forEach((act, index) => {
138
+ if (!staffedRepositories.has(act.repository)) report("R3", `packages[${index}].repository`, "is not spelled exactly as any staffing[].repository");
139
+ });
140
+ // R4: planItem ids are unique.
141
+ for (const { index, first } of repeats(acts.map((act) => act.planItem))) {
142
+ report("R4", `packages[${index}].planItem`, `repeats packages[${first}].planItem`);
143
+ }
144
+ // R5: one act per (repository, name); repositories compare case-insensitively.
145
+ for (const { index, first } of repeats(acts.map((act) => JSON.stringify([lower(act.repository), act.name])))) {
146
+ report("R5", `packages[${index}].name`, `repeats the repository and name of packages[${first}] (repositories compare case-insensitively)`);
147
+ }
148
+ // R12: every planItem is exactly its act's repository, a colon and its name, in the same letter case.
149
+ acts.forEach((act, index) => {
150
+ if (act.planItem !== `${act.repository}:${act.name}`) report("R12", `packages[${index}].planItem`, "is not this act's repository, a colon and its name, in the same letter case");
151
+ });
152
+ // R10: at most one pin-starter act per repository (compared case-insensitively), and it is a devDependency.
153
+ const pinAt = acts.flatMap((act, index) => (act.act === "pin-starter" ? [index] : []));
154
+ for (const { index, first } of repeats(pinAt.map((position) => lower(acts[position]!.repository)))) {
155
+ report("R10", `packages[${pinAt[index]}].act`, `is a second act of this kind for the repository of packages[${pinAt[first]}] (repositories compare case-insensitively)`);
156
+ }
157
+ for (const position of pinAt) {
158
+ if (acts[position]!.placement !== "devDependencies") report("R10", `packages[${position}].placement`, "must be devDependencies for a pin-starter act");
159
+ }
160
+ }
161
+
162
+ // R6: resolution is present exactly when packages is.
163
+ const hasPackages = Object.hasOwn(plan, "packages");
164
+ const hasResolution = Object.hasOwn(plan, "resolution");
165
+ if (hasPackages && !hasResolution) report("R6", "resolution", "is required when packages is present");
166
+ if (!hasPackages && hasResolution) report("R6", "resolution", "must be absent when there are no packages");
167
+
168
+ // R7: kit ids are unique.
169
+ for (const { index, first } of repeats((kits ?? []).map((kit) => kit.id))) {
170
+ report("R7", `kits[${index}].id`, `repeats kits[${first}].id`);
171
+ }
172
+
173
+ // R9: no role twice in mandate.roles.
174
+ for (const { index, first } of repeats(mandateRoles)) {
175
+ report("R9", `mandate.roles[${index}]`, `repeats mandate.roles[${first}]`);
176
+ }
177
+
178
+ return found;
179
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Ensures the registry file `writer-check` reads is the same store a surface
3
+ * passes to `createCopyResolver` at render — never a second, request-scoped
4
+ * module the CLI does not scan.
5
+ */
6
+
7
+ import { realpathSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { validateCopyRegistryShape } from "./schema.js";
10
+ import type { CopyRegistry } from "./types.js";
11
+
12
+ export type RenderRegistryParityReason =
13
+ | "render-registry-path-mismatch"
14
+ | "record-not-copy-registry";
15
+
16
+ export interface RenderRegistryParityResult {
17
+ ok: boolean;
18
+ reason?: RenderRegistryParityReason;
19
+ detail?: string;
20
+ registry?: CopyRegistry;
21
+ }
22
+
23
+ /**
24
+ * Confirms `recordFile` is a `CopyRegistry` and, when `renderRegistryFile`
25
+ * is supplied, that both paths resolve to the same file (the render store
26
+ * and the gate store must be one file).
27
+ */
28
+ export function checkRenderRegistryParity(
29
+ recordFile: string,
30
+ recordValue: unknown,
31
+ renderRegistryFile?: string,
32
+ ): RenderRegistryParityResult {
33
+ const registryFindings = validateCopyRegistryShape(recordValue);
34
+ if (registryFindings.length > 0) {
35
+ const detail = registryFindings.map((f) => f.message).join("; ");
36
+ return {
37
+ ok: false,
38
+ reason: "record-not-copy-registry",
39
+ detail: `record-file is not a CopyRegistry (the store createCopyResolver reads): ${detail}`,
40
+ };
41
+ }
42
+
43
+ if (renderRegistryFile !== undefined) {
44
+ const recordReal = safeRealpath(resolve(recordFile));
45
+ const renderReal = safeRealpath(resolve(renderRegistryFile));
46
+ if (recordReal !== renderReal) {
47
+ return {
48
+ ok: false,
49
+ reason: "render-registry-path-mismatch",
50
+ detail:
51
+ "record-file and --render-registry must be the same file — a second approved store the CLI does not read is not an approved registry.",
52
+ };
53
+ }
54
+ }
55
+
56
+ return { ok: true, registry: recordValue as CopyRegistry };
57
+ }
58
+
59
+ function safeRealpath(path: string): string {
60
+ try {
61
+ return realpathSync(path);
62
+ } catch {
63
+ return path;
64
+ }
65
+ }
package/src/resolve.ts CHANGED
@@ -7,16 +7,29 @@
7
7
  * source provenance, and an approved entry. The result exposes every one of
8
8
  * those inputs so `@example/surface` can place them in its output
9
9
  * manifest without knowing how a consumer stores copy.
10
+ *
11
+ * Target policy: an owner-approved entry resolves on both `"preview"` and
12
+ * `"production"` (the default target). A delegate-approved entry resolves
13
+ * freely on `"preview"`, but on `"production"` is refused
14
+ * (`delegate-approval-refused`) unless the caller opts in with
15
+ * `acceptDelegateInProduction: true`, or passes `approvalPlan`, the bytes of
16
+ * a plan whose latest approval names that plan's own digest and which
17
+ * declares `delegatedCopyApproval` covering the entry — a delegate's sign-off
18
+ * is not, by itself, sufficient evidence to publish, only to preview.
10
19
  */
11
20
 
12
21
  import type {
13
22
  CopyRef,
14
23
  CopyRegistry,
15
24
  CopyResolution,
25
+ CopyResolutionApproval,
16
26
  CopyResolver,
17
27
  CopyValue,
18
28
  } from "./types.js";
19
29
  import { validateCopyRegistryShape } from "./schema.js";
30
+ import { isApprovalExpired, isApprovalStale, isEntryInDelegateScope } from "./approval.js";
31
+ import { isPlanBytes, planDelegateCopyAuthority } from "./plan-authority.js";
32
+ import type { PlanDelegateCopyRefusal } from "./plan-authority.js";
20
33
 
21
34
  export type CopyResolveIssueReason =
22
35
  | "invalid-registry"
@@ -25,13 +38,43 @@ export type CopyResolveIssueReason =
25
38
  | "unknown-copy-id"
26
39
  | "copy-not-approved"
27
40
  | "missing-placeholder-value"
28
- | "unexpected-placeholder-value";
41
+ | "unexpected-placeholder-value"
42
+ | "invalid-options"
43
+ | "approval-stale"
44
+ | "approval-expired"
45
+ | "delegate-approval-refused";
46
+
47
+ /** Which audience a resolution is for — see this file's top doc comment for the policy difference. */
48
+ export type CopyResolveTarget = "preview" | "production";
49
+
50
+ /** Options controlling how strictly `resolveCopyRef`/`createCopyResolver` treat an entry's approval record. */
51
+ export interface CopyResolveOptions {
52
+ /** Defaults to `"production"`. */
53
+ target?: CopyResolveTarget;
54
+ /** Defaults to `false`. Only meaningful when `target` is `"production"`. */
55
+ acceptDelegateInProduction?: boolean;
56
+ /** Defaults to `new Date()`, evaluated per call. */
57
+ now?: Date;
58
+ /**
59
+ * The bytes of an Advisor plan record (a `Uint8Array`; Node's `Buffer` is
60
+ * one), read by the caller. Consulted only for a delegate-approved entry on
61
+ * `"production"` when `acceptDelegateInProduction` is not `true`: see
62
+ * `planDelegateCopyAuthority()`. Not a `Uint8Array`, or given together with
63
+ * `acceptDelegateInProduction: true`, is `"invalid-options"`.
64
+ */
65
+ approvalPlan?: Uint8Array;
66
+ }
67
+
68
+ /** Why an `approvalPlan` did not authorize an entry: the plan's own refusal, or that the entry is outside the scopes the plan declares. */
69
+ export type CopyResolvePlanRefusal = PlanDelegateCopyRefusal | "entry-outside-plan-scopes";
29
70
 
30
71
  export interface CopyResolveIssue {
31
72
  reason: CopyResolveIssueReason;
32
73
  message: string;
33
74
  id?: string;
34
75
  placeholder?: string;
76
+ /** Only on `"delegate-approval-refused"` when an `approvalPlan` was given: why it did not authorize this entry. */
77
+ planRefusal?: CopyResolvePlanRefusal;
35
78
  }
36
79
 
37
80
  export interface CopyResolveResult {
@@ -56,13 +99,60 @@ function interpolate(text: string, values: Readonly<Record<string, CopyValue>>):
56
99
  return text.replace(/\{([^{}]+)\}/g, (_match, name: string) => String(values[name]!));
57
100
  }
58
101
 
102
+ /** The options a call runs with, each read from the caller's object exactly once. */
103
+ interface ResolveSettings {
104
+ target: CopyResolveTarget;
105
+ acceptDelegateInProduction: boolean;
106
+ now: Date;
107
+ approvalPlan: Uint8Array | undefined;
108
+ }
109
+
110
+ /**
111
+ * Reads a well-formed `CopyResolveOptions`, or returns null — checked before
112
+ * anything else so a malformed caller-supplied options object fails closed
113
+ * rather than silently falling back to defaults. Each property is read
114
+ * exactly once, into a local that is validated and then used, so a getter
115
+ * cannot answer differently at validation and at use.
116
+ */
117
+ function readResolveOptions(value: unknown): ResolveSettings | null {
118
+ if (value === undefined) return { target: "production", acceptDelegateInProduction: false, now: new Date(), approvalPlan: undefined };
119
+ if (!isPlainObject(value)) return null;
120
+ const { target, acceptDelegateInProduction, now, approvalPlan } = value;
121
+ if (target !== undefined && target !== "preview" && target !== "production") return null;
122
+ if (acceptDelegateInProduction !== undefined && typeof acceptDelegateInProduction !== "boolean") return null;
123
+ if (now !== undefined && !(now instanceof Date && Number.isFinite(now.getTime()))) return null;
124
+ if (approvalPlan !== undefined && !isPlanBytes(approvalPlan)) return null;
125
+ // Two authorities for one decision is an ambiguity, so it is refused: no precedence needs defining.
126
+ if (approvalPlan !== undefined && acceptDelegateInProduction === true) return null;
127
+ return {
128
+ target: target ?? "production",
129
+ acceptDelegateInProduction: acceptDelegateInProduction ?? false,
130
+ now: now ?? new Date(),
131
+ approvalPlan,
132
+ };
133
+ }
134
+
59
135
  /**
60
136
  * Resolves a `CopyRef` against one locale-specific registry. Any absence,
61
- * lifecycle failure, locale mismatch, or placeholder mismatch produces no
62
- * text and a non-complete result. Consumers may log `issues`; renderers must
63
- * treat a missing `resolution` as an unresolved required input.
137
+ * lifecycle failure, locale mismatch, staleness/expiry, delegate-in-production
138
+ * refusal, or placeholder mismatch produces no text and a non-complete
139
+ * result. Consumers may log `issues`; renderers must treat a missing
140
+ * `resolution` as an unresolved required input.
64
141
  */
65
- export function resolveCopyRef(registry: CopyRegistry | unknown, ref: CopyRef | unknown): CopyResolveResult {
142
+ export function resolveCopyRef(
143
+ registry: CopyRegistry | unknown,
144
+ ref: CopyRef | unknown,
145
+ options?: CopyResolveOptions | unknown,
146
+ ): CopyResolveResult {
147
+ const settings = readResolveOptions(options);
148
+ if (settings === null) {
149
+ return {
150
+ issues: [{ reason: "invalid-options", message: "CopyResolveOptions is malformed." }],
151
+ complete: false,
152
+ };
153
+ }
154
+ const { target, acceptDelegateInProduction, now, approvalPlan } = settings;
155
+
66
156
  const registryFindings = validateCopyRegistryShape(registry);
67
157
  if (registryFindings.length > 0) {
68
158
  return {
@@ -119,6 +209,69 @@ export function resolveCopyRef(registry: CopyRegistry | unknown, ref: CopyRef |
119
209
  };
120
210
  }
121
211
 
212
+ const approval = entry.approval;
213
+ // Set only when an approvalPlan, not the flag, authorized a delegate entry on production.
214
+ let authorizingPlanDigest: string | undefined;
215
+ if (approval) {
216
+ if (isApprovalStale(entry)) {
217
+ return {
218
+ issues: [
219
+ {
220
+ reason: "approval-stale",
221
+ id: validRef.id,
222
+ message: `CopyRef "${validRef.id}"'s approval record no longer matches its current text.`,
223
+ },
224
+ ],
225
+ complete: false,
226
+ };
227
+ }
228
+ if (isApprovalExpired(approval, now)) {
229
+ return {
230
+ issues: [
231
+ {
232
+ reason: "approval-expired",
233
+ id: validRef.id,
234
+ message: `CopyRef "${validRef.id}"'s approval expired at ${approval.expiresAt}.`,
235
+ },
236
+ ],
237
+ complete: false,
238
+ };
239
+ }
240
+ if (approval.approvedBy === "delegate" && target === "production" && acceptDelegateInProduction !== true) {
241
+ // The flag did not authorize this entry, so only a plan can. The plan is
242
+ // read here and nowhere else: never for preview, an owner entry, a
243
+ // record-less entry, or once the flag is true, and never before the
244
+ // entry's own staleness and expiry, which outrank it.
245
+ if (approvalPlan === undefined) {
246
+ return {
247
+ issues: [
248
+ {
249
+ reason: "delegate-approval-refused",
250
+ id: validRef.id,
251
+ message: `CopyRef "${validRef.id}" was approved by a delegate; production resolution requires acceptDelegateInProduction: true.`,
252
+ },
253
+ ],
254
+ complete: false,
255
+ };
256
+ }
257
+ const planRefused = (code: CopyResolvePlanRefusal): CopyResolveResult => ({
258
+ issues: [
259
+ {
260
+ reason: "delegate-approval-refused",
261
+ id: validRef.id,
262
+ planRefusal: code,
263
+ message: `CopyRef "${validRef.id}" was approved by a delegate; the approvalPlan given does not authorize it on production (${code}).`,
264
+ },
265
+ ],
266
+ complete: false,
267
+ });
268
+ const authority = planDelegateCopyAuthority(approvalPlan);
269
+ if (!authority.authorized) return planRefused(authority.refusal);
270
+ if (authority.scopes !== undefined && !isEntryInDelegateScope(entry.id, authority.scopes)) return planRefused("entry-outside-plan-scopes");
271
+ authorizingPlanDigest = authority.planDigest;
272
+ }
273
+ }
274
+
122
275
  const values = validRef.values ?? {};
123
276
  const issues: CopyResolveIssue[] = [];
124
277
  const declared = new Set(entry.placeholders ?? []);
@@ -151,22 +304,35 @@ export function resolveCopyRef(registry: CopyRegistry | unknown, ref: CopyRef |
151
304
  }
152
305
  if (issues.length > 0) return { issues, complete: false };
153
306
 
154
- return {
155
- resolution: {
156
- ref: validRef as unknown as CopyRef,
157
- text: interpolate(entry.text, values as Readonly<Record<string, CopyValue>>),
158
- recordId: validRegistry.id,
159
- revision: validRegistry.revision,
160
- locale: validRegistry.locale,
161
- source: validRegistry.source,
162
- entryId: entry.id,
163
- },
164
- issues: [],
165
- complete: true,
307
+ const resolution: CopyResolution = {
308
+ ref: validRef as unknown as CopyRef,
309
+ text: interpolate(entry.text, values as Readonly<Record<string, CopyValue>>),
310
+ recordId: validRegistry.id,
311
+ revision: validRegistry.revision,
312
+ locale: validRegistry.locale,
313
+ source: validRegistry.source,
314
+ entryId: entry.id,
166
315
  };
316
+ // Only set when the entry actually carries a record — an approved entry
317
+ // with no record resolves exactly as it did before this field existed,
318
+ // with no `approval` key on the resolution at all.
319
+ if (approval) {
320
+ // Built explicitly, so the digest key exists only when a plan authorized the entry.
321
+ const facts: CopyResolutionApproval =
322
+ approval.approvedBy === "owner"
323
+ ? { approvedBy: "owner", pendingOwnerReview: false }
324
+ : {
325
+ approvedBy: "delegate",
326
+ pendingOwnerReview: approval.pendingOwnerReview === true,
327
+ ...(authorizingPlanDigest === undefined ? {} : { authorizingPlanDigest }),
328
+ };
329
+ resolution.approval = facts;
330
+ }
331
+
332
+ return { resolution, issues: [], complete: true };
167
333
  }
168
334
 
169
335
  /** Creates the narrow resolver callback for presentation code. */
170
- export function createCopyResolver(registry: CopyRegistry | unknown): CopyResolver {
171
- return (ref) => resolveCopyRef(registry, ref).resolution;
336
+ export function createCopyResolver(registry: CopyRegistry | unknown, options?: CopyResolveOptions | unknown): CopyResolver {
337
+ return (ref) => resolveCopyRef(registry, ref, options).resolution;
172
338
  }