@classytic/primitives 0.22.0 → 0.24.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.
package/CHANGELOG.md CHANGED
@@ -3,8 +3,56 @@
3
3
  Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
4
  adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [0.24.0] - 2026-08-24
7
+
8
+ ### Added — `@classytic/primitives/address`: capture-format system
9
+
10
+ `AddressFormat`, `AddressFieldDescriptor`, `AddressFieldKey`, `PartialAddress`, `AddressFieldError` — a description of HOW a country collects a `ContactAddress`: field labels, order, requiredness, subdivision taxonomy hooks, and postal code pattern. Country packs (e.g. `@classytic/bd-areas/address-format`) live beside their taxonomies; this subpath owns only the generic shape.
11
+
12
+ `validateAddress(address, format)` — validates a partial address against a format; returns `AddressFieldError[]`. Enforces `required` on both canonical and extension fields identically, and checks `postalCodePattern` only when a value is given and a pattern is declared. `isAddressValid` and `describeAddressError` are convenience wrappers.
13
+
14
+ Every `key` in a descriptor is a field of `ContactAddress`; co-location with the value object is what makes a mismatch obvious instead of invisible.
15
+
16
+ ## [0.23.0] - 2026-08-15
17
+
18
+ ### Added — `./approval`: `notRequiredChain(reason)` + `isNotRequired(chain)`
19
+
20
+ The chain for a subject that needs **no** approval — zero steps, already resolved. The
21
+ explicit, greppable answer to "the policy engine ran and decided this document does not
22
+ require approval", which callers previously had no way to express.
23
+
24
+ This states a conclusion the module already reached rather than inventing one:
25
+ `computeChainStatus` folds an empty step list to `'approved'` — "nothing outstanding" is
26
+ what approved MEANS here, and that fold predates this function. `createChain` still throws
27
+ `EMPTY_STEPS`: building a ROUTING chain that routes nowhere is a different mistake and
28
+ stays refused. The two are deliberately not interchangeable, so reaching "approved with no
29
+ approvers" requires saying so by name.
30
+
31
+ **Why a chain rather than leaving the field unset.** Absence is the one input that occurs
32
+ by accident — a schema that never declared `approvals` strips it on write — and
33
+ `assertApproved` must keep treating that as `CHAIN_MISSING`. A present zero-step chain is
34
+ distinguishable from a lost chain (absent) *and* from a granted one (has steps and
35
+ decisions), so existing gates keep working through `isApproved` alone. No consumer needs a
36
+ second predicate, which is what would drift.
37
+
38
+ `isNotRequired` tests the STRUCTURE (`steps.length === 0`), not the new
39
+ `notRequiredReason` field: a subject schema that never declared that field drops it on
40
+ write, so a predicate reading it would answer differently before and after the save. The
41
+ reason is provenance for the audit trail only — pinned by a test that strips it and asserts
42
+ the predicate is unmoved.
43
+
44
+ **The hazard, stated in the docblock:** this makes `isApproved` true with no human
45
+ decision. It is correct only where a policy engine ASKED and got "no policy applies" —
46
+ never as a fallback for a lookup that failed or was never configured. First consumer:
47
+ `@classytic/arc-approval` 0.4.0 `whenNoPolicy: 'not-required'`.
48
+
6
49
  ## [0.22.0] - 2026-08-12
7
50
 
51
+ ### Added — outbox fencing tokens (`claimPendingFenced`)
52
+
53
+ - **`OutboxStore.claimPendingFenced?`** (additive, feature-detected — mirrors repo-core's `LockAdapter.tryAcquireFenced`): same atomic semantics as `claimPending`, returning `OutboxClaimedEvent { event, fencingToken }`. The token is minted by the STORE per claim epoch — a takeover after lease expiry mints STRICTLY GREATER, so a stale ex-holder's `acknowledge`/`fail` (now accepting `fencingToken?`) throws `OutboxOwnershipError`. This closes the overlap that consumer-id ownership checks narrow but cannot close: holder ids recur; epochs do not.
54
+ - `MemoryOutboxStore` implements it; `claimPending` delegates to the fenced path (one claim implementation). Stale-ack rejection is mutation-proven.
55
+
8
56
  ### Added — `./period`: `shiftPeriod(period, n)` — calendar arithmetic without instants
9
57
 
10
58
  Shift a MONTH or QUARTER period by `n` of its own units (negative = back); YEAR shifts by years; a `custom` (ad-hoc start/end) period has no unit to shift and throws rather than guessing one. No `Date` is constructed and no zone is consulted — shifting a month is pure `(year, month)` arithmetic, and the `timezone` rides along unchanged so the shifted period still resolves against the calendar it was defined in.
@@ -5,6 +5,11 @@
5
5
  * Kept deliberately generic — international postal formats vary too much to
6
6
  * enforce per-country rules in a shared type. Consumers can extend or narrow
7
7
  * as needed (e.g. a BD-specific schema might add `district`, `upazila`).
8
+ *
9
+ * That still holds, and the CAPTURE FORMAT section below does not weaken it. A
10
+ * format constrains no type and adds no field: it is opt-in DATA describing how
11
+ * a country asks for the fields declared here. The type stays generic precisely
12
+ * so one canonical shape can be captured many ways.
8
13
  */
9
14
  interface Address {
10
15
  line1: string;
@@ -34,5 +39,269 @@ interface GeoJsonPoint {
34
39
  }
35
40
  declare function toGeoJsonPoint(p: GeoPoint): GeoJsonPoint;
36
41
  declare function fromGeoJsonPoint(g: GeoJsonPoint): GeoPoint;
42
+ /**
43
+ * How a country's postal address is CAPTURED — labels, order, requiredness, and
44
+ * which fields come from a subdivision taxonomy rather than free text.
45
+ *
46
+ * ## Why this lives HERE, beside the value object
47
+ *
48
+ * Storage was already standardised: an address is `ContactAddress` above. What
49
+ * varied — and varied by being hardcoded — is CAPTURE. The checkout form asked
50
+ * for "Division", "District" and "Delivery Area" because the deployment is
51
+ * Bangladeshi, so selling in a second country meant editing the form. That is
52
+ * the defect `@classytic/ledger` already solved for charts of accounts: the
53
+ * ENGINE was generic and the BINDING was not.
54
+ *
55
+ * This first went into a separate `@classytic/address` package. Wrong, for the
56
+ * reason this codebase has just paid for twice: a description of a shape that
57
+ * lives away from the shape DRIFTS from it. `Order`'s Mongoose schema said
58
+ * `addressLine1` while the type it declared said `line1`, and nothing could see
59
+ * the contradiction because they were in different files. Every `key` below is
60
+ * a field of `ContactAddress`; putting them in the same file is what makes a
61
+ * mismatch obvious instead of invisible.
62
+ *
63
+ * The COUNTRY PACKS do not live here — they are localizations, and they follow
64
+ * the `ledger` / `ledger-bd` precedent: Bangladesh's is
65
+ * `@classytic/bd-areas/address-format`, beside the taxonomy it selects from.
66
+ * Nothing in this file knows what a division is.
67
+ *
68
+ * ## What a format does NOT do
69
+ *
70
+ * It does not introduce new stored fields. Every descriptor's `key` is a field
71
+ * of `ContactAddress`, so a format is a VIEW over one canonical shape:
72
+ * Bangladesh labels `state` "Division", Japan labels it "Prefecture", the United
73
+ * States labels it "State". Same column, same value object, different word.
74
+ *
75
+ * A country that genuinely needs an extra datum (a thana, an upazila, a landmark)
76
+ * puts it in `metadata` — primitives' own guidance — never in a new top-level
77
+ * field, because that would make the stored shape country-dependent again.
78
+ */
79
+ /** The `ContactAddress` fields a format may lay out. */
80
+ type AddressFieldKey = 'name' | 'phone' | 'email' | 'company' | 'line1' | 'line2' | 'city' | 'state' | 'postalCode' | 'country';
81
+ /**
82
+ * How a field is entered.
83
+ *
84
+ * `subdivision` is the interesting one: the value is still a plain string on the
85
+ * stored address, but it is CHOSEN from a taxonomy rather than typed. Bangladesh
86
+ * picks a division then a district; India picks a state; the US picks a state.
87
+ * The taxonomy itself is supplied by the host through `AddressTaxonomyPort` —
88
+ * this package ships no place names, so it stays country-agnostic and small.
89
+ */
90
+ type AddressFieldInput = 'text' | 'tel' | 'email' | 'subdivision';
91
+ interface AddressFieldSpec {
92
+ /** The canonical `ContactAddress` field this captures. */
93
+ key: AddressFieldKey;
94
+ /** Label in the deployment's language. */
95
+ label: string;
96
+ input: AddressFieldInput;
97
+ required?: boolean;
98
+ /** Spans the full row in a two-column form. */
99
+ wide?: boolean;
100
+ placeholder?: string;
101
+ /** Help text under the field. */
102
+ hint?: string;
103
+ /**
104
+ * For `input: 'subdivision'` — the taxonomy level this field selects, resolved
105
+ * through `AddressTaxonomyPort`. Opaque to this package: `'division'`,
106
+ * `'district'`, `'area'`, `'state'`, `'prefecture'` are all just strings.
107
+ */
108
+ taxonomy?: string;
109
+ /**
110
+ * The field whose value narrows this one's options — Bangladesh's district
111
+ * list depends on the chosen division. Must appear EARLIER in `fields`.
112
+ */
113
+ dependsOn?: string;
114
+ }
115
+ /**
116
+ * A field a country needs that `ContactAddress` does not model.
117
+ *
118
+ * ## Why this exists rather than growing the value object
119
+ *
120
+ * Bangladesh's checkout captures a **Delivery Area** — the locality couriers
121
+ * actually route and price on, finer than a district. It is required there and
122
+ * meaningless in most countries.
123
+ *
124
+ * The two obvious answers are both wrong. Adding `areaName` to `ContactAddress`
125
+ * puts one market's concept in the base type every country pays for; leaving it
126
+ * out entirely means the country that most needs a format cannot express its
127
+ * most important field, which is how the form ends up hardcoded again.
128
+ *
129
+ * So an extension field is DECLARED like any other — same labels, same
130
+ * requiredness, same taxonomy machinery — but its `name` is free-form and the
131
+ * consuming DOMAIN decides where the value lands. `@classytic/order` promotes
132
+ * `areaId` / `areaName` / `zoneId` to real columns because its carrier adapters
133
+ * read them; anything it does not recognise goes to `metadata`. Neither
134
+ * decision belongs in this package, and neither requires a release of it.
135
+ *
136
+ * Values live FLAT alongside the canonical ones, not in a nested bag — which is
137
+ * how `areaName` is already stored, and it means a form produces one object
138
+ * rather than two that can disagree.
139
+ */
140
+ interface AddressExtensionFieldSpec {
141
+ /** Discriminant. */
142
+ extension: true;
143
+ /**
144
+ * Free-form key. Not validated against `ContactAddress` — that is the point.
145
+ * The domain maps it to a column or to `metadata`.
146
+ */
147
+ name: string;
148
+ label: string;
149
+ input: AddressFieldInput;
150
+ required?: boolean;
151
+ wide?: boolean;
152
+ placeholder?: string;
153
+ hint?: string;
154
+ taxonomy?: string;
155
+ /** May name a canonical key OR another extension. Must appear EARLIER. */
156
+ dependsOn?: string;
157
+ }
158
+ type AnyAddressFieldSpec = AddressFieldSpec | AddressExtensionFieldSpec;
159
+ /** The key a spec writes to, canonical or extension. */
160
+ declare function fieldName(spec: AnyAddressFieldSpec): string;
161
+ /** Narrowing helper — `true` when the spec is an extension. */
162
+ declare function isExtensionField(spec: AnyAddressFieldSpec): spec is AddressExtensionFieldSpec;
163
+ interface AddressFormat {
164
+ /** ISO 3166-1 alpha-2, or `'*'` for the international fallback. */
165
+ country: string;
166
+ /** Display name of the country, for a read-only country field. */
167
+ countryLabel: string;
168
+ /** Ordered — this is the form layout. Canonical and extension fields interleave. */
169
+ fields: AnyAddressFieldSpec[];
170
+ /**
171
+ * Validate `postalCode` when present. Omit for countries where the code is
172
+ * free-form or absent; a wrong pattern is worse than none, because it refuses
173
+ * addresses that are perfectly valid.
174
+ */
175
+ postalCodePattern?: RegExp;
176
+ }
177
+ /**
178
+ * A taxonomy level's options, narrowed by the parent selection.
179
+ *
180
+ * A HOST PORT, not data in this package: Bangladesh's divisions live in
181
+ * `@classytic/bd-areas`, and a kernel that imported them would be a
182
+ * country-agnostic package with one country's place names compiled into it.
183
+ */
184
+ interface AddressTaxonomyPort {
185
+ /**
186
+ * @param level the `taxonomy` string from the field spec
187
+ * @param parentId the selected value of `dependsOn`, when the field has one
188
+ */
189
+ options(level: string, parentId?: string): Array<{
190
+ id: string;
191
+ label: string;
192
+ hint?: string;
193
+ }>;
194
+ }
195
+ /**
196
+ * The generic international format — the FALLBACK, and the proof the contract is
197
+ * not BD-shaped.
198
+ *
199
+ * Deliberately conservative. `state` is present but optional because many
200
+ * countries have no meaningful subdivision in a postal address (Singapore,
201
+ * Vatican City), and `postalCode` is optional because several have no postal
202
+ * code at all (Ireland pre-Eircode, Hong Kong, UAE). Requiring either would make
203
+ * the DEFAULT refuse valid addresses — the failure mode a fallback must not have.
204
+ *
205
+ * The required set is exactly what `Fulfillment` needs to be deliverable:
206
+ * `line1`, `city`, `country`.
207
+ */
208
+ declare const INTERNATIONAL_ADDRESS_FORMAT: AddressFormat;
209
+ /** Look a CANONICAL field up by its key. Extension fields have no `key`. */
210
+ declare function fieldSpec(format: AddressFormat, key: AddressFieldKey): AddressFieldSpec | undefined;
211
+ /**
212
+ * The CANONICAL keys a format marks required.
213
+ *
214
+ * Canonical only, deliberately: callers use this to check that a format can
215
+ * produce a deliverable address (`line1` + `city` + `country`), and an extension
216
+ * name has no meaning in that question. Use `requiredFieldNames` for everything
217
+ * a form must collect.
218
+ */
219
+ declare function requiredKeys(format: AddressFormat): AddressFieldKey[];
220
+ /** Every field name a format requires, canonical and extension alike. */
221
+ declare function requiredFieldNames(format: AddressFormat): string[];
222
+ /**
223
+ * A capture in progress.
224
+ *
225
+ * Extension values sit FLAT beside the canonical ones — `areaName` alongside
226
+ * `city` — because that is how the order kernel already stores them, and a
227
+ * nested bag would be a second place for the same address to disagree with
228
+ * itself. The index signature is what lets a format declare a field this
229
+ * package has never heard of.
230
+ */
231
+ type PartialAddress = Partial<Record<AddressFieldKey, string>> & Partial<Pick<ContactAddress, 'line1' | 'city' | 'country'>> & {
232
+ [extension: string]: unknown;
233
+ };
234
+ /**
235
+ * Country code → capture format. The host builds this, exactly as it builds the
236
+ * ledger's `REGISTRY` (`be-prod/.../accounting/country-pack.ts`).
237
+ */
238
+ type AddressFormatRegistry = Readonly<Record<string, AddressFormat>>;
239
+ /**
240
+ * Resolve the format for a country, falling back to the international one.
241
+ *
242
+ * ## Falling back is the point, not a concession
243
+ *
244
+ * A deployment that has not authored a pack for Norway must still be able to
245
+ * ship to Norway. The fallback captures the deliverable minimum with neutral
246
+ * labels, which is strictly better than either refusing the address or showing
247
+ * a Bangladeshi form to a Norwegian.
248
+ *
249
+ * The lookup is case-insensitive because `country` reaches this from three
250
+ * directions — a form, a stored order, an API payload — and ISO codes get
251
+ * written `bd` about as often as `BD`. Normalising here beats a `?.toUpperCase()`
252
+ * at every call site, one of which will be forgotten and will silently fall back
253
+ * to international for its own home country.
254
+ */
255
+ declare function resolveAddressFormat(country: string | null | undefined, registry: AddressFormatRegistry): AddressFormat;
256
+ /**
257
+ * The countries a deployment has packs for — for a country picker.
258
+ *
259
+ * A deployment that ships internationally should offer every ISO country and let
260
+ * unlisted ones resolve to the fallback; this is for the common case of an
261
+ * operator choosing among the markets actually configured.
262
+ */
263
+ declare function supportedCountries(registry: AddressFormatRegistry): Array<{
264
+ code: string;
265
+ label: string;
266
+ }>;
267
+ interface AddressFieldError {
268
+ /**
269
+ * The field's write key — a canonical `AddressFieldKey` or an extension name.
270
+ * Typed `string` because a format may declare fields this package does not
271
+ * know; a form matches it against `fieldName(spec)`.
272
+ */
273
+ key: string;
274
+ /** A closed code — callers map it to their own copy. */
275
+ code: 'required' | 'postal_code_format';
276
+ /** The field's own label, so a default message reads correctly per country. */
277
+ label: string;
278
+ }
279
+ /**
280
+ * Validate a captured address against its country's format.
281
+ *
282
+ * ## Returns errors; does not throw, and does not "clean"
283
+ *
284
+ * A form needs every failure at once to mark every field, so this returns a
285
+ * list. It also never mutates or drops anything: a validator that silently
286
+ * repaired input would make the stored value differ from what the operator
287
+ * typed, and they would have no way to see it.
288
+ *
289
+ * ## Codes, not sentences
290
+ *
291
+ * `code` is closed and stable so a host can translate. `label` rides along
292
+ * because the same `state` field is "Division" in Bangladesh and "Prefecture"
293
+ * in Japan, and a default message assembled from the code alone would say the
294
+ * wrong word in one of them.
295
+ *
296
+ * ## Whitespace is absence
297
+ *
298
+ * `' '` satisfies a naive truthiness check and then prints as a blank line on
299
+ * a courier label. Required means a value, not a character.
300
+ */
301
+ declare function validateAddress(address: PartialAddress | null | undefined, format: AddressFormat): AddressFieldError[];
302
+ /** Convenience for a submit gate. */
303
+ declare function isAddressValid(address: PartialAddress | null | undefined, format: AddressFormat): boolean;
304
+ /** A readable default message, in the country's own vocabulary. */
305
+ declare function describeAddressError(error: AddressFieldError): string;
37
306
  //#endregion
38
- export { Address, ContactAddress, GeoJsonPoint, GeoPoint, fromGeoJsonPoint, toGeoJsonPoint };
307
+ export { Address, AddressExtensionFieldSpec, AddressFieldError, AddressFieldInput, AddressFieldKey, AddressFieldSpec, AddressFormat, AddressFormatRegistry, AddressTaxonomyPort, AnyAddressFieldSpec, ContactAddress, GeoJsonPoint, GeoPoint, INTERNATIONAL_ADDRESS_FORMAT, PartialAddress, describeAddressError, fieldName, fieldSpec, fromGeoJsonPoint, isAddressValid, isExtensionField, requiredFieldNames, requiredKeys, resolveAddressFormat, supportedCountries, toGeoJsonPoint, validateAddress };
package/dist/address.mjs CHANGED
@@ -12,5 +12,194 @@ function fromGeoJsonPoint(g) {
12
12
  latitude
13
13
  };
14
14
  }
15
+ /** The key a spec writes to, canonical or extension. */
16
+ function fieldName(spec) {
17
+ return "extension" in spec ? spec.name : spec.key;
18
+ }
19
+ /** Narrowing helper — `true` when the spec is an extension. */
20
+ function isExtensionField(spec) {
21
+ return "extension" in spec;
22
+ }
23
+ /**
24
+ * The generic international format — the FALLBACK, and the proof the contract is
25
+ * not BD-shaped.
26
+ *
27
+ * Deliberately conservative. `state` is present but optional because many
28
+ * countries have no meaningful subdivision in a postal address (Singapore,
29
+ * Vatican City), and `postalCode` is optional because several have no postal
30
+ * code at all (Ireland pre-Eircode, Hong Kong, UAE). Requiring either would make
31
+ * the DEFAULT refuse valid addresses — the failure mode a fallback must not have.
32
+ *
33
+ * The required set is exactly what `Fulfillment` needs to be deliverable:
34
+ * `line1`, `city`, `country`.
35
+ */
36
+ const INTERNATIONAL_ADDRESS_FORMAT = {
37
+ country: "*",
38
+ countryLabel: "Country",
39
+ fields: [
40
+ {
41
+ key: "name",
42
+ label: "Recipient Name",
43
+ input: "text",
44
+ required: true
45
+ },
46
+ {
47
+ key: "phone",
48
+ label: "Phone",
49
+ input: "tel",
50
+ required: true
51
+ },
52
+ {
53
+ key: "line1",
54
+ label: "Address Line 1",
55
+ input: "text",
56
+ required: true,
57
+ wide: true
58
+ },
59
+ {
60
+ key: "line2",
61
+ label: "Address Line 2",
62
+ input: "text",
63
+ wide: true
64
+ },
65
+ {
66
+ key: "city",
67
+ label: "City",
68
+ input: "text",
69
+ required: true
70
+ },
71
+ {
72
+ key: "state",
73
+ label: "State / Province / Region",
74
+ input: "text"
75
+ },
76
+ {
77
+ key: "postalCode",
78
+ label: "Postal Code",
79
+ input: "text"
80
+ },
81
+ {
82
+ key: "country",
83
+ label: "Country",
84
+ input: "text",
85
+ required: true
86
+ }
87
+ ]
88
+ };
89
+ /** Look a CANONICAL field up by its key. Extension fields have no `key`. */
90
+ function fieldSpec(format, key) {
91
+ return format.fields.find((f) => !isExtensionField(f) && f.key === key);
92
+ }
93
+ /**
94
+ * The CANONICAL keys a format marks required.
95
+ *
96
+ * Canonical only, deliberately: callers use this to check that a format can
97
+ * produce a deliverable address (`line1` + `city` + `country`), and an extension
98
+ * name has no meaning in that question. Use `requiredFieldNames` for everything
99
+ * a form must collect.
100
+ */
101
+ function requiredKeys(format) {
102
+ return format.fields.filter((f) => !isExtensionField(f) && !!f.required).map((f) => f.key);
103
+ }
104
+ /** Every field name a format requires, canonical and extension alike. */
105
+ function requiredFieldNames(format) {
106
+ return format.fields.filter((f) => f.required).map(fieldName);
107
+ }
108
+ /**
109
+ * Resolve the format for a country, falling back to the international one.
110
+ *
111
+ * ## Falling back is the point, not a concession
112
+ *
113
+ * A deployment that has not authored a pack for Norway must still be able to
114
+ * ship to Norway. The fallback captures the deliverable minimum with neutral
115
+ * labels, which is strictly better than either refusing the address or showing
116
+ * a Bangladeshi form to a Norwegian.
117
+ *
118
+ * The lookup is case-insensitive because `country` reaches this from three
119
+ * directions — a form, a stored order, an API payload — and ISO codes get
120
+ * written `bd` about as often as `BD`. Normalising here beats a `?.toUpperCase()`
121
+ * at every call site, one of which will be forgotten and will silently fall back
122
+ * to international for its own home country.
123
+ */
124
+ function resolveAddressFormat(country, registry) {
125
+ if (!country) return INTERNATIONAL_ADDRESS_FORMAT;
126
+ return registry[country.trim().toUpperCase()] ?? INTERNATIONAL_ADDRESS_FORMAT;
127
+ }
128
+ /**
129
+ * The countries a deployment has packs for — for a country picker.
130
+ *
131
+ * A deployment that ships internationally should offer every ISO country and let
132
+ * unlisted ones resolve to the fallback; this is for the common case of an
133
+ * operator choosing among the markets actually configured.
134
+ */
135
+ function supportedCountries(registry) {
136
+ return Object.entries(registry).map(([code, format]) => ({
137
+ code,
138
+ label: format.countryLabel
139
+ })).sort((a, b) => a.label.localeCompare(b.label));
140
+ }
141
+ /**
142
+ * Validate a captured address against its country's format.
143
+ *
144
+ * ## Returns errors; does not throw, and does not "clean"
145
+ *
146
+ * A form needs every failure at once to mark every field, so this returns a
147
+ * list. It also never mutates or drops anything: a validator that silently
148
+ * repaired input would make the stored value differ from what the operator
149
+ * typed, and they would have no way to see it.
150
+ *
151
+ * ## Codes, not sentences
152
+ *
153
+ * `code` is closed and stable so a host can translate. `label` rides along
154
+ * because the same `state` field is "Division" in Bangladesh and "Prefecture"
155
+ * in Japan, and a default message assembled from the code alone would say the
156
+ * wrong word in one of them.
157
+ *
158
+ * ## Whitespace is absence
159
+ *
160
+ * `' '` satisfies a naive truthiness check and then prints as a blank line on
161
+ * a courier label. Required means a value, not a character.
162
+ */
163
+ function validateAddress(address, format) {
164
+ const errors = [];
165
+ const value = (k) => String(address?.[k] ?? "").trim();
166
+ /**
167
+ * Canonical and extension fields are checked IDENTICALLY, keyed by
168
+ * `fieldName`. A required Delivery Area is enforced exactly as a required
169
+ * city is — an extension that validated more weakly than a core field would
170
+ * be an optional field wearing a required label.
171
+ */
172
+ for (const field of format.fields) {
173
+ const name = fieldName(field);
174
+ if (field.required && !value(name)) errors.push({
175
+ key: name,
176
+ code: "required",
177
+ label: field.label
178
+ });
179
+ }
180
+ /**
181
+ * Only when a pattern is declared AND a value was given. An absent optional
182
+ * postal code is not a format error, and a country with no declared pattern
183
+ * must not have one invented for it — see `AddressFormat.postalCodePattern`.
184
+ */
185
+ const postal = value("postalCode");
186
+ if (format.postalCodePattern && postal && !format.postalCodePattern.test(postal)) errors.push({
187
+ key: "postalCode",
188
+ code: "postal_code_format",
189
+ label: fieldSpec(format, "postalCode")?.label ?? "Postal Code"
190
+ });
191
+ return errors;
192
+ }
193
+ /** Convenience for a submit gate. */
194
+ function isAddressValid(address, format) {
195
+ return validateAddress(address, format).length === 0;
196
+ }
197
+ /** A readable default message, in the country's own vocabulary. */
198
+ function describeAddressError(error) {
199
+ switch (error.code) {
200
+ case "required": return `${error.label} is required`;
201
+ case "postal_code_format": return `${error.label} is not in the expected format`;
202
+ }
203
+ }
15
204
  //#endregion
16
- export { fromGeoJsonPoint, toGeoJsonPoint };
205
+ export { INTERNATIONAL_ADDRESS_FORMAT, describeAddressError, fieldName, fieldSpec, fromGeoJsonPoint, isAddressValid, isExtensionField, requiredFieldNames, requiredKeys, resolveAddressFormat, supportedCountries, toGeoJsonPoint, validateAddress };
@@ -71,6 +71,14 @@ interface ApprovalChain {
71
71
  readonly order: ChainOrder;
72
72
  readonly steps: readonly ApprovalStep[];
73
73
  readonly status: ApprovalStatus;
74
+ /**
75
+ * Provenance for a chain built by {@link notRequiredChain} — why no approval
76
+ * was needed. PROVENANCE ONLY: never branch on it. A subject schema that
77
+ * does not declare this field STRIPS it on write (Mongoose strict mode), so
78
+ * a predicate reading it would answer differently before and after the save.
79
+ * {@link isNotRequired} tests the structure instead, which no schema can drop.
80
+ */
81
+ readonly notRequiredReason?: string;
74
82
  }
75
83
  interface CreateChainInput {
76
84
  readonly order: ChainOrder;
@@ -101,6 +109,42 @@ declare class ApprovalError extends Error {
101
109
  }
102
110
  /** Build a fresh chain in `pending` state. Validates inputs. */
103
111
  declare function createChain(input: CreateChainInput): ApprovalChain;
112
+ /**
113
+ * The chain for a subject that needs NO approval — zero steps, already
114
+ * resolved. The explicit, greppable answer to "the policy engine ran and
115
+ * decided this document does not require approval."
116
+ *
117
+ * ## Why this is not a fabricated approval
118
+ *
119
+ * {@link computeChainStatus} already folds an empty step list to `'approved'`
120
+ * — "nothing is outstanding" is what approved MEANS to this primitive, and
121
+ * that fold predates this function. What {@link createChain} refuses
122
+ * (`EMPTY_STEPS`) is building a ROUTING chain that routes nowhere, which is a
123
+ * different mistake and stays refused. So this constructor states the same
124
+ * conclusion the fold reaches, under a name a reviewer can search for, rather
125
+ * than letting a caller reach it by handing `createChain` an empty array.
126
+ *
127
+ * ## Why a chain at all, instead of leaving `approvals` absent
128
+ *
129
+ * Because absence is the one input that occurs by accident. A schema that
130
+ * never declared the field strips it, a failed write leaves it unset — and
131
+ * {@link assertApproved} must keep treating that as CHAIN_MISSING. A present,
132
+ * zero-step chain is distinguishable from both a lost chain (absent) and a
133
+ * granted one (has steps and decisions), so every existing gate keeps working
134
+ * unchanged and no caller needs a second predicate beside `isApproved`.
135
+ *
136
+ * ## The hazard, stated plainly
137
+ *
138
+ * This makes {@link isApproved} true without a human deciding anything. It is
139
+ * only correct where a policy engine ASKED and got "no policy applies" — never
140
+ * as a fallback for a lookup that failed, errored, or was not configured. A
141
+ * resolver that cannot reach its policy store must throw; answering "not
142
+ * required" there would auto-approve every document in the deployment, which
143
+ * is precisely the permissive-default failure this codebase designs against.
144
+ *
145
+ * @param reason Why approval was not required, for the audit trail.
146
+ */
147
+ declare function notRequiredChain(reason: string): ApprovalChain;
104
148
  /**
105
149
  * Returns the next step awaiting a decision — or `null` if none.
106
150
  *
@@ -133,6 +177,20 @@ declare function applyDecision(chain: ApprovalChain, input: DecisionInput): Appr
133
177
  */
134
178
  declare function skipStep(chain: ApprovalChain, stepId: string, reason?: string): ApprovalChain;
135
179
  declare function isApproved(chain: ApprovalChain): boolean;
180
+ /**
181
+ * Was this chain built by {@link notRequiredChain} — i.e. approval did not
182
+ * apply, as opposed to having been granted?
183
+ *
184
+ * Both are `isApproved`, and for GATING that is correct: neither blocks the
185
+ * operation. Use this only where the difference is worth showing — an audit
186
+ * trail or an approvals UI, where rendering "Approved" for a document nobody
187
+ * approved misleads the reader.
188
+ *
189
+ * Tests the STRUCTURE (no steps ⇒ nobody was ever asked), not
190
+ * `notRequiredReason`, which a subject schema that never declared the field
191
+ * silently drops on write.
192
+ */
193
+ declare function isNotRequired(chain: ApprovalChain): boolean;
136
194
  interface AssertApprovedOptions {
137
195
  /** Override the thrown message. */
138
196
  readonly message?: string;
@@ -184,4 +242,4 @@ declare function isPending(chain: ApprovalChain): boolean;
184
242
  */
185
243
  declare function decisionCount(chain: ApprovalChain): number;
186
244
  //#endregion
187
- export { ApprovalChain, ApprovalDecision, ApprovalError, ApprovalErrorCode, ApprovalStatus, ApprovalStep, ApprovalThreshold, Approver, AssertApprovedOptions, ChainOrder, CreateChainInput, CreateStepInput, DecisionInput, applyDecision, assertApproved, assertApprovedIfPresent, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
245
+ export { ApprovalChain, ApprovalDecision, ApprovalError, ApprovalErrorCode, ApprovalStatus, ApprovalStep, ApprovalThreshold, Approver, AssertApprovedOptions, ChainOrder, CreateChainInput, CreateStepInput, DecisionInput, applyDecision, assertApproved, assertApprovedIfPresent, createChain, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };