fix(service-settings): re-point value-domains.ts onto @objectstack/spec/shared and refuse with value_domain - #15434
Conversation
…ue-domain predicate and refuse with `value_domain`
The services half of the maintainer's ruling A (2026-09-02): one closed
vocabulary and one membership predicate shared by settings specifiers and
object fields. The spec half landed the shared module; this deletes the
door's second copy of all three definitions and re-points onto it.
- `value-domains.ts` keeps only what is the DOOR's — which declarations it
enforces, how a multi-value carrier is walked, and the env log line's
prose. The `Intl.DateTimeFormat` probe, the run-time
`Intl.supportedValuesOf('currency')` set and the 249 alpha-2 codes are
gone; `isValueDomainMember` answers instead.
- `knownValueDomain` filters with the closed enum's own `safeParse`, which
makes the prototype-chain guard structural rather than remembered.
- The save door's refusal code becomes `value_domain` (ADR-0114: the code is
the constraint's own name) and its message renders the published catalog
template, the same catalog the record write path renders.
- A ratchet pin so a re-added membership table in this package goes red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…e ratchet's input radius - `settings-routes.test.ts` gains a pin that states the refusal envelope as a CHANGE: `value_domain` (not `invalid_value`) plus the catalog sentence, with status, envelope code, `field`, `label`, `constraint` and `value` asserted unchanged beside it, so the blast radius is measured rather than believed. - The encrypted-specifier case pins the redacted sentence: the catalog template always interpolates the offending value, so a secret key renders with the REST boundary's mask and the value still never appears. - The ratchet routes its comment stripping through `scripts/js-comment-mask.mjs` (`check:comment-mask-adoption` refuses a private one) and declares that import's radius in `cross-package-test-inputs.mjs` + `turbo.json`, so the pin is visible to the affected-subset filter and the turbo cache. - Changeset: `minor` + a BREAKING banner, which is the carrier for breaking-ness inside the launch window where `major` is forbidden. `packages/spec/liveness/field.json` is deliberately NOT touched: the same `valueDomain` row is being rewritten by the open engine-half PR, so an edit here would conflict and force the ledger counts to be re-derived. The sentence this branch falsifies is reported to the PM to sequence instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a54c5b90a71aae01ab996ed6bcf465fc238d78ca && git checkout a54c5b90a71aae01ab996ed6bcf465fc238d78ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 954cb0bbfa02a5f19b8d82f64179a5e9a211bbe0 6f2f77edf9be1837b69f7e5a87386115eb4a71f8 && git checkout -B drift-repro 954cb0bbfa02a5f19b8d82f64179a5e9a211bbe0 && git merge --no-ff 6f2f77edf9be1837b69f7e5a87386115eb4a71f8
node scripts/docs-audit/affected-docs.mjs --json 954cb0bbfa02a5f19b8d82f64179a5e9a211bbe0
|
Contract review — PR #15434 (Clause-②), head
|
| mutation | pin | result |
|---|---|---|
M1 export const LOCAL_TABLE = new Set(['CH']) appended to value-domains.ts |
ratchet pin.test.ts:80 |
red 1/5 |
M2 new src/zz-probe-local.ts carrying supportedValuesOf('currency') + 'AD AE AF AG AI AL AM AO AQ' |
ratchet :90, :98 |
red 2/5 |
M3 new src/zz-second-caller.ts calling isValueDomainMember( |
ratchet :107 |
red 1/5 |
M6 spec alpha-2 literal drops CH |
value-domains.test.ts:169 |
red 2/14 |
M6b spec iso_3166_alpha2: () => true |
:42 garbage probe |
red 2/14 |
M7 snapshot drops CHF: 2, (currency-fraction-digits.ts:55) |
:143 re-measurement |
red 3/14 |
M8 example: 'Europe/Berlin' in valueDomainPhrasing |
:88 catalog-prose |
red 1/14 |
M9 value: String(offending) (mask dropped) |
settings-service.test.ts vaultdom |
red 1/139 |
M10 code: 'invalid_value' |
settings-routes.test.ts |
red 2/26 |
M11 messageKey: 'value_domain' (generic template) |
settings-routes.test.ts |
red 1/26 |
M12 door asks String(v).toLowerCase() |
value-domains.test.ts |
red 5/14 |
So every pin measures something. But the ratchet measures the spelling of the deleted table, not the capability:
- M4 — green 5/5. The M2 file placed at
src/manifests/zz-alpha2-table.tsinstead ofsrc/.runtimeSources()isreaddirSync(SRC)(pin.test.ts:59), non-recursive;src/manifests/andsrc/translations/exist today. - M5 — green: ratchet 5/5 AND
value-domains.test.ts14/14. A full 249-code table as an array literal (new Set(['AD','AE',…])) in a newsrc/zz-alpha2-array.ts, imported byvalue-domains.tsand consulted foriso_3166_alpha2whileisValueDomainMemberis kept for the other two domains.RUN(:90) matches only the space-separated string shape;new Set(is checked only invalue-domains.ts(:80); "exactly one caller" (:107) still holds. This is precisely the "move the table to a new file" case the header (:28-31) claims the whole-package scan closes.
2. The admitted vacuous pin — confirmed; the replacement questions are non-vacuous, one is control-shaped.
Found: packages/spec/src/shared/value-domain.test.ts:38 expect(SpecifierValueDomainSchema).toBe(ValueDomainSchema) (identity; :39 options equal) and again packages/spec/src/system/settings-manifest.test.ts:408 — both on main, untouched by this PR. Replacement (a) "every member actually refuses something" (value-domains.test.ts:42): red under M6b (accept-all), but green under M6c — a denylist stub (v) => ['ZZ','UK','XX','us'].includes(v) passes 14/14; the spec's own test catches M6c (3 failed of 18), so the system is not blind, only the door-level question is. Replacement (b) prose-vs-catalog (:88): red under M8, non-vacuous. Also :163-173 loops ISO_3166_ALPHA2_CODES through a door that answers from ISO_3166_ALPHA2_CODES — the set against itself: what it pins is the door's plumbing (M12 catches it), not membership; its comment "stronger and not a duplicate" overstates.
3. The labelling correction — confirmed. At base a56baa2bd: settings-service.ts:1025 is const rejected = firstRejectedDomainMember(domain, value); inside private effectiveEnvOverride( (declared :963); :2060 is the same call inside private async validatePatch( (declared :1883). On the head they sit at :1031 (in effectiveEnvOverride, :969) and :2074 (in validatePatch, :1889). Only validatePatch pushes a FieldError (:2091-2109); the env door writes a log line via reportRejectedEnvOverride (:1102). Both doors are covered; the dev did not invert it again.
4. Information disclosure — confirmed on every path, by probe. A temporary probe test (deleted afterwards; 5/5) registered a manifest with encrypted: true + valueDomain on a text key and a multiselect key plus a plain control key, canaries Mars/CANARY7731 / CNRY:
- save door, scalar:
fields[0].message=TZ secret must be a valid IANA time zone identifier, e.g. Europe/Zurich (got "••••••••"),fields[0].valueabsent;JSON.stringify({message, fields, stack})canary-free. - save door, multiselect encrypted: masked,
valueabsent, canary-free. - REST
PUT /api/settings/:namespacethroughregisterSettingsRoutes: the whole{status, body}is canary-free — including top-levelerror.message, which isSettingsValidationError's concatenation of field messages (settings-service.types.ts:589-592) and therefore carries the mask. - env door, encrypted key: one log line, no value — the
Rejected value:clause is dropped (settings-service.ts:1124-1125) and the sentence is built fromvalueDomainPhrasing, which has no{{value}}. - positive controls: the plain key echoes the canary on the save door, the REST door and the env log line, so the detector is live.
Where the mask is applied:settings-service.ts:2100(value: secret ? SETTINGS_SECRET_MASK : String(offending)) and:2108(...(secret ? {} : { value })), withsecret = reg.encryptedKeys.has(key)(:2084) — the same setsecretKeysOf()(:1456-1460) hands the REST read mask, so write-time redaction and read-time masking cannot disagree.renderValidationMessage(validation-message.ts:300-337) is called with notranslatehook and no locale (→en), so no override path can re-render the raw value. Only non-test consumer of the error issettings-routes.ts:155-166, verbatim pass-through. M9 shows the vaultdom pin catches a dropped mask.
5. Scope growth into tooling — confirmed sanctioned; one nit. check-cross-package-test-inputs.mjs:1444-1470 (Layer B) requires a turbo.json "<pkg>#test" task hashing the declared globs, so both edits are the gate's own prescribed shape. Re-run on the head: check-cross-package-test-inputs.mjs (+--self-test) exit 0; check-comment-mask-adoption.mjs (+--self-test, invoked as lint.yml:505-506 does — it is not a root script) exit 0; check-turbo-task-graph.mjs exit 0. inputs = the default four + the two mask files, the same shape as eight sibling entries; package-scoped, so no other package's affected-subset filter moves. Not minimal: dependsOn: ["build"] (turbo.json:27) vs the default ["^build"], copied from @objectstack/metadata#test; nothing in this package's tests reads its own dist (every workspace import is aliased to src; settings-prebind-read-warning.test.ts:42-46 says so), and the commit message gives no reason. Harmless (a self-build before tests) but unexplained.
6. Currency re-measurement pin — defect (→ R2). value-domains.test.ts:143-157 asserts at run time that Intl.supportedValuesOf('currency') ⊆ door-admits, while the door now answers from CURRENCY_FRACTION_DIGITS. The spec already carries the drift detector beside the snapshot: packages/spec/src/shared/value-domain.test.ts:121-131 asserts probe == snapshot in both directions plus size === 162, and the snapshot header carries the regeneration snippet. CI runs node-version: '22' (ci.yml:313 et al.; .nvmrc = 22) — a floating patch, so the ICU build moves with each 22.x release. On any host whose ICU enumerates a currency the snapshot lacks, this test goes red for a correct implementation — at the same moment the spec's does, in a package that has nothing to fix (the fix is regenerating the spec snapshot); on a host that lost one, only the spec's goes red. It is a strictly weaker duplicate of the spec's detector, in the wrong package. Measured here: probe == snapshot (A3), so green today; the CI host's ICU is not measured from this seat. What it should assert instead: iterate Object.keys(CURRENCY_FRACTION_DIGITS) through firstRejectedDomainMember (deterministic: "every snapshot member is admitted through the door" — the thing this package owns) and leave probe-vs-snapshot to spec. Side effect: the ratchet's test-source exemption for supportedValuesOf (pin.test.ts:31-33) loses its only reason to exist.
7. Changeset disposition — confirmed. ADR-0087's categories reason about metadata objectstack migrate meta can reach (check-adr-0087-registration.mjs:186-188; ADR D2 scope at docs/adr/0087…md:139: "losslessly mappable changes only — renames, alias removals, field moves"); a FieldError.code on a REST refusal is not that, and no registered/already-registered alternative fits. no-migration-prescription is mechanically valid (no FROM/TO block; the gate's exit 0 was measured by the PM and is not re-derived here). Launch-window rule: check-changeset-no-major.mjs:33-56 makes the BREAKING banner + ADR-0087 disposition the carriers; WHICH LEVEL (pr-automation.yml:667-682) puts a behaviour change on a published package at minor. Re-ran node scripts/check-changeset-no-major.mjs --base origin/main --head HEAD → exit 0; check-empty-changeset.mjs → exit 0. Nit: the body says the settings door renders "the same catalog the record write path renders" — on this tree nothing outside packages/spec and service-settings renders value_domain at all (grep -rln value_domain packages --include=*.ts minus tests/spec/settings: zero hits); the engine half is an open PR. Present tense for a future state.
8. Equivalence measurements — confirmed. A2 (string literals extracted mechanically from base value-domains.ts and packages/spec/src/shared/value-domain.zod.ts): 249/249, 249 unique each, order identical, symmetric difference ∅, all ^[A-Z]{2}$. A3 (tsx over CURRENCY_FRACTION_DIGITS vs Intl.supportedValuesOf('currency')): 162/162, both differences ∅, CHF in both, XYZ in neither, VED/XAU in neither. Node v22.22.2 / ICU 78.2 / CLDR 48.0.
Not in 1–8
- Full package suite on the head:
pnpm exec vitest run→ 30 files / 527 tests green;tsc --noEmitexit 0; also exit 0 on the head:check-test-source-alias,check-undeclared-dep-imports,check-error-code-casing,check-dispatcher-error-vocabulary,check-pnpm-filter-targets,check-type-source-resolution. - No published-surface loss:
index.tsnever re-exportedvalue-domains.ts(checked at base and head), soISO_3166_ALPHA2_CODESleaving the module changes no package export. - Console (
objectui@24e027e):apps/console/src/pages/settings/SettingsView.tsx:223,233branch only on envelope codes and the valueDomain test renders a plainerrorstring — the first-party client does not break on the code move.SettingsField.tsx:177andSettingsField.valueDomain.test.tsx:221still sayinvalid_valuein prose: a sibling-repo follow-up, not a blocker. - Residual
invalid_valueinsettings-service.ts(:173,:180,:377,:1831,:1835,:1942,:1947) are the step-grid (settings 的step是同族第五个声明但不执行的值约束键 —— 先要一个「它算不算值约束」的判断 #6199) andvisible-predicate (settings-manifestvisibleis declaredExpressionInputSchema(CEL) but evaluated by a non-CEL grammar — a CEL predicate there silently skips the save-timerequiredgate #7169) refusals — correct as-is. - The core pin the ratchet header cites exists:
packages/core/src/security/resolve-authz-context.time-zone-domain.pin.test.ts.
Required changes (tests only; the implementation stays as is)
R1 — value-domains.shared-predicate.pin.test.ts: make the ratchet cover what its header claims. (a) Scan recursively (readdirSync(SRC, { recursive: true }) or an explicit walk) so src/manifests/, src/translations/ and any new subdirectory are covered — M4. (b) Detect the array-literal table shape in every runtime source, e.g. /(?:'[A-Z]{2}',\s*){7}/ beside RUN — M5. Do not widen the new Set( check package-wide: settings-service.ts (6 sites) and sys-secret-orphan-report.ts (1) use it legitimately; instead pin value-domains.ts's import surface (no relative imports, only @objectstack/spec/shared), which is what closes the M5 route. Alternatively narrow the header at :28-31 to what is measured. Evidence: M4 and M5 above.
R2 — value-domains.test.ts:143-157: replace the live Intl.supportedValuesOf('currency') loop with a loop over Object.keys(CURRENCY_FRACTION_DIGITS) through firstRejectedDomainMember; the probe-vs-snapshot invariant already lives at packages/spec/src/shared/value-domain.test.ts:121. The A3 measurement belongs in the PR/changeset record (already there), not as a run-time invariant on a floating-ICU host. Then drop the supportedValuesOf exemption sentence from the ratchet header.
Nits, waived: turbo.json:27 dependsOn: ["build"] → ["^build"] or a one-line reason; the changeset sentence "the same catalog the record write path renders" → future tense or cite the engine PR; value-domains.test.ts:163-173 comment "stronger and not a duplicate" → "pins the door's plumbing"; the objectui prose follow-up.
Not measured: the CI host's ICU/CLDR (claim 6 is argued from the floating node-version: '22' pin and measured only on v22.22.2); the ADR-0087 gate exit (PM-measured, not re-derived).
Generated by Claude Code
…ew found, and drop the ICU-drift currency pin
R1 — the ratchet did not cover what its header claimed. Two mutations passed
it green: a table one directory down (`src/manifests/`, which `readdirSync`
never reached) and a 249-code ARRAY literal in a sibling module that
`value-domains.ts` imported and consulted for `iso_3166_alpha2` while the
shared predicate still served the other two.
- the source walk is recursive, and reports paths relative to `src/`;
- both table SHAPES are detected — the space-separated string and the array
literal a fresh re-typing produces;
- the root close is a new import-surface pin: `value-domains.ts` may import
from `@objectstack/spec/shared` and NOTHING else, so a table anywhere in the
tree is harmless because the door cannot reach it. `new Set(` is deliberately
NOT widened package-wide — `settings-service.ts` has six legitimate sites;
- the header now states what is and is not covered, at the size of the
evidence.
R2 — the currency pin asserted at run time that everything
`Intl.supportedValuesOf('currency')` enumerates is admitted by a door that
answers from the checked-in snapshot. CI pins the Node MAJOR only, so an ICU
build that enumerates one code the snapshot lacks would redden a CORRECT
implementation in a package with nothing to fix — and probe-versus-snapshot is
already pinned beside the snapshot, in both directions and with a size. It is
replaced by the invariant this package owns: the door AGREES with
`isValueDomainMember` value by value, over a corpus carrying every trap, so a
local filter creeping back into the walker reddens even though every
representative case would still pass.
Also, from the review's waived list: `turbo.json` uses `^build` (nothing in
this package's tests reads its own dist); the changeset's "the same catalog the
record write path renders" is future tense and cites the open engine PR; and
the alpha-2 population pin is described as the plumbing pin it is rather than
as something stronger.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
|
Round 1 addressed — patched head R1 — the ratchet now covers what its header claimsThree changes, and the third is the one that closes M5 at its root rather than by recognising a shape:
Both mutations re-run against the patched pin. Each was written to disk, the mutation confirmed on disk by observing the anchor text (never the writer's exit code —
One observation from M5 worth recording: the door suite stays green (15/15) under it, correctly — the duplicate table has identical membership, so no behavioural test can see it. That is the argument for the ratchet being a source scan, and for the import pin being the load-bearing check rather than the shape regexes. The header now states what is covered and what is not — a 3-letter currency array is not shape-detected, and a table reached through a bare package specifier would pass the import pin — so the claim is the size of the evidence rather than the other way round. R2 — the ICU-drift pin is goneReplaced by the invariant this package actually owns: One deviation from the suggested wording, flagged rather than silently reshaped: looping The ratchet header's Waived items — all three taken, since the files were open
Not acted on, as instructed: the note that "every member actually refuses something" is weaker than it reads. Recorded in the PR body so the pin is not over-trusted. Verification on the patched headGate family re-derived on this head (same 57 families, no additions) and re-run in full: 57 run · 57 exit 0 · 0 red, with the workspace closure built first ( Generated by Claude Code |
Contract review — PR #15434 (Clause-②), round 2, head
|
| mutation | ratchet | door suite |
|---|---|---|
M4 — space-separated table at src/manifests/zz-alpha2-table.ts |
red 1 failed | 5 passed on carries no ISO 3166-1 alpha-2 code list, in either shape |
— |
M5 — 249-code array literal in src/zz-alpha2-array.ts, from './zz-alpha2-array.js', consulted for iso_3166_alpha2, predicate kept for the other two |
red 2 failed | 4 passed on imports from NOTHING ELSE… and the shape case |
green 15/15 — as the dev said: identical membership, no behaviour to see |
M5q — M5 verbatim, with the array strings and the specifier double-quoted (from "./zz-alpha2-array.js", ["AD", "AE", …]) |
green 6/6 | green 15/15 |
M16 — the 249 codes as a regex alternation inside the door itself (/^(?:AD|AE|…|ZW)$/): no import, no quotes, no new Set( |
green 6/6 | green 15/15 |
M5q: the import-surface pin (pin.test.ts:115, /\bfrom\s+'([^']+)'/g) and both shape regexes (:139 '(?:[A-Z]{2} ){7}, :140 (?:'[A-Z]{2}',\s*){7}) recognise single quotes only, so the pin sees ['@objectstack/spec/shared'] and passes. It is lint-legal: eslint --print-config for this path lists 6 active rules and no quotes / @stylistic/quotes / @typescript-eslint/quotes; a live control (import { Data } from "@objectstack/spec" in a src/ file) exits 1 on no-restricted-imports while the double-quoted strings beside it draw no report; ESLint over the M5q files exits 0. The header's NOT-covered list (:49-53) names a 3-letter currency array and a bare package specifier — not this — and the check it defeats is the one the header calls the root close (:44-47, :108-113). Same class, not run: from"./x" (no whitespace — \s+ requires one) and a template-literal or leading-space spaced table. Demonstrated in a scratch script over the M5q door text: /\bfrom\s*(['"])([^'"]+)\1/g sees ["@objectstack/spec/shared","./zz-alpha2-array.js"]; /(?:(['"])[A-Z]{2}\1,\s*){7}/ catches the double-quoted array; both plus /(['"])(?:[A-Z]{2} ){7}/` return 0 hits across the 31 runtime sources on the head. M16 is the admitted class ("a shape it does not know") — belongs in the NOT-covered list, not required.
R2 — the time bomb is gone; the substitute does not cover the class its header claims
Discharged: value-domains.test.ts no longer probes Intl.supportedValuesOf (:145-195), and the ratchet header's supportedValuesOf exemption sentence is gone (pin.test.ts:55 is now the bare "test sources are exempt"). The dev's premise is confirmed: CURRENCY_FRACTION_DIGITS is declared at packages/spec/src/data/currency-fraction-digits.ts:51, not re-exported by data/index.ts, absent from api-surface/*.json, and spec's exports map has no wildcard — unreachable from this package without widening a published surface; round 1's literal suggestion was infeasible and flagging it was right. ICU-independent: yes, structurally — both sides of every assertion are computed, so no host can redden a delegating door. Probe-vs-snapshot is where the dev says: packages/spec/src/shared/value-domain.test.ts:120-130, both directions plus size 162.
Not discharged. The header at :150-153 says "a local filter creeping back in (an extra case fold, a length or shape check, a curated allow-list) reddens here". Measured in value-domains.ts:
| mutation | ratchet | door suite | whole package |
|---|---|---|---|
M14 — iso_4217_currency answered by ['CHF','USD','EUR','GBP','JPY','CNY','INR','AUD','CAD','BRL'].includes(v), predicate kept for the other two |
green 6/6 | green 15/15 | green 529/529 |
M15 — predicate AND !v.startsWith('X') (drops XAF, XOF, XCD, XPF — real currencies) |
green 6/6 | green 15/15 | — |
The corpus (:165-174) carries four currency members, all inside M14's list; outside this file the package pushes only CHF, USD, XYZ through the door; and the ratchet says itself that a 3-letter array is not shape-detected. So a second currency membership definition inside the settings door — the exact divergence the ruling closed — ships green through every gate in this package. The alpha-2 door gets its whole population (:208-212, 249 codes); the currency door gets four samples and a false sentence. The regression class round 1 named ("every snapshot member is admitted through the door") is not covered.
A reachable, ICU-independent, population-level form exists — the same agreement shape with the host's enumeration as the POPULATION, not the ORACLE: for each c of Intl.supportedValuesOf('currency'), door(c) === null must equal isValueDomainMember('iso_4217_currency', c). A correct door can never redden on any ICU (a code the snapshot lacks is false on both sides; a code the host lacks is never asked). Measured with a scratch tsx probe at the package root: head → population=162 disagreements=0; M14 → disagreements=152 (AED, AFN, ALL, …); M15 → disagreements=7 (XAF, XCD, XCG, XDR, XOF, XPF, XSU). This does not reverse round 1: the objection was to the enumeration as an oracle over a snapshot-backed door; under an agreement assertion it carries no ICU verdict. Test sources stay exempt from the ratchet scan, so nothing there moves.
Waived nits — confirmed taken, cheaply
turbo.json:27 dependsOn: ["^build"]; the changeset is future-tense and cites #15316; :200-207 calls the alpha-2 loop the plumbing pin it is. Gates that touch the changed ratchet file, re-run on the clean head: check-cross-package-test-inputs.mjs exit 0 (27 packages, every declared glob hashed); check-comment-mask-adoption.mjs and --self-test exit 0; turbo.json inputs still name both mask files.
Required changes (tests only — value-domains.ts / settings-service.ts untouched and still cleared)
R1′ — value-domains.shared-predicate.pin.test.ts: make the import-surface pin and both shape regexes delimiter-agnostic: :115 → /\bfrom\s*(['"])([^'"]+)\1/g (read group 2); :139 → /(['"])(?:[A-Z]{2} ){7}/; :140→/(?:(['"])[A-Z]{2}\1,\s*){7}/. Evidence: M5q green 6/6 on the head; the replacements see the relative specifier and the double-quoted array, and hit 0 of 31 runtime sources. Then either add M16's class (an alternation or .includestable inside the door itself) to the NOT-covered list at:49-53`, or pin the door's stripped code against any run of ≥7 two-uppercase-letter tokens in any delimiter.
R2′ — value-domains.test.ts:145-195: add a population-level agreement case for iso_4217_currency over Intl.supportedValuesOf('currency') (optionally iana_time_zone over supportedValuesOf('timeZone') in the same form), asserting door === predicate per code, with a comment stating the enumeration is the population and not the oracle. Evidence: M14 and M15 are green in the ratchet, the door suite and (M14) the whole package today, and red under that case by 152 and 7 disagreements. Make the sentence at :150-153 true or delete the allow-list clause.
Not measured: the CI host's ICU (the ICU-independence argument is structural; measured only on 78.2); the other 55 gate families (PM-measured on this head; no runtime file moved).
Generated by Claude Code
…ain agreement over populations rather than samples Round 2 of the contract review found both instruments narrower than their own headers claimed, and measured exactly how much. R1' — every scan was single-quote-shaped, so a VERBATIM double-quoted copy of the array mutation passed the whole file green: the import pin read `/\bfrom\s+'([^']+)'/` and both shape regexes hard-coded `'`. This package has no `quotes` lint rule active, so both spellings are legal here and only these pins can tell them apart. - the import surface is read as `/\bfrom\s*(['"])([^'"]+)\1/` — either delimiter, and the space after `from` optional; - the two table shapes accept `'`, `"` and `` ` ``; - a third shape needs no quotes and no import at all — the 249 codes as a regex alternation inside the door — so it is caught by DENSITY rather than spelling: seven or more bare two-uppercase-letter tokens separated by one or two non-alphanumerics, checked on the door, which is the only place the shape can live now that the import pin holds. All three scans hit 0 of the 31 runtime sources on this head. R2' — the agreement pin covered a corpus, and its header claimed it covered "a curated allow-list". Measured false: a door answering `iso_4217_currency` from a ten-entry `.includes` list passed the whole package green, because all four sampled currency members were inside that list. Agreement is now asserted over each domain's whole POPULATION — every code `Intl.supportedValuesOf` gives for currency and time zone, and the published 249 for alpha-2 — with the corpus kept for the traps no population contains. The host enumeration is the POPULATION here, never the ORACLE, which is what made the round-1 version a time bomb: each code is put to BOTH the door and the shared predicate and the two must answer alike, so a code the snapshot lacks is false on both sides and a code the host lacks is never asked. No ICU build can redden a correct door — or hide a divergent one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
|
Round 2 addressed — patched head R1′ — the delimiter, and the alternationThe three replacements are in as prescribed, and hit 0 of the 31 runtime sources on this head: M16 is closed rather than documented. A new door-only check catches the shape by DENSITY, which is what survives every spelling: The NOT-covered list is now honest about what remains: a 3-letter table (a currency list) is not shape-detected in any spelling, and a bare-specifier import would pass the import pin. Currency is reached behaviourally instead — see R2′. R2′ — populations, not samplesThe header sentence is now true. Agreement is asserted over each domain's whole population, with the corpus kept only for traps no population contains:
Enumeration as population, never oracle, exactly as you framed it: each code goes to the door and to the predicate and the two must answer alike, so a code the snapshot lacks is false on both sides and a code the host lacks is never asked. Round 1's objection is preserved intact — no ICU build can redden a correct door, and none can hide a divergent one either. Mutation results — all four, each confirmed on disk and restored under a trap
Both currency numbers reproduce yours exactly. Worth recording: under M14 and M15 the ratchet stays green 7/7, and under M5q and M16 the door suite stays green — the two instruments are catching disjoint classes, which is the argument for keeping both rather than merging them. On your method note — taken, and applied here rather than only read. Each population pin carries a vacuity guard ( Confirmed and unchanged: Verification on the patched headGate family re-derived on this head (same 57, none added, none dropped) and re-run in full: 57 run · 57 exit 0 · 0 red, with the workspace closure built first ( Generated by Claude Code |
Fixes #15162
The services half of the maintainer's ruling A of 2026-09-02 on #14168: one closed vocabulary and one membership predicate shared by settings specifiers and object fields. The spec half landed the shared module in #15133; this deletes
service-settings' second copy of all three definitions, re-points the door ontoisValueDomainMember, and adopts the catalog member the same ruling created.Clause ② — contract review is owed on this PR. Rounds 1 and 2 both returned CHANGES REQUIRED, both entirely in the test files;
value-domains.tsandsettings-service.tshave been byte-identical since round 1 cleared them. Sections 8 and 9 carry each round's fixes with the mutations that prove them.1 · The wire-visible change
Measured on
PUT /api/settings/localizationwith body{"timezone": "Mars/Olympus"}, basea56baa2bdvs this branch. The BEFORE row was produced by checking the two files out at the base into this same tree and running the same script against them (restore verified byte-identical afterwards bygit hash-objectagainst the HEAD blobs), not reconstructed from the diff.error.details.fields[0]codeinvalid_valuevalue_domainmessageDefault timezone must be a valid IANA time zone identifier (e.g. 'Europe/Zurich'). Received 'Mars/Olympus'.Default timezone must be a valid IANA time zone identifier, e.g. Europe/Zurich (got "Mars/Olympus")fieldtimezonetimezonelabelDefault timezoneDefault timezoneconstraint{ valueDomain: 'iana_time_zone' }{ valueDomain: 'iana_time_zone' }valueMars/OlympusMars/OlympusSETTINGS_VALIDATIONSETTINGS_VALIDATIONSo the break is exactly one cell wide plus the sentence. A client reading
constraint.valueDomain— the machine-readable half ADR-0114 asks it to read — is unaffected; a client branching oncode === 'invalid_value'for a domain breach must move.Why the code moved (seat ruling 2 on #15133, implemented as directed and not re-decided here): ADR-0114's rule is that the code is the constraint's own name, the way
max_lengthnames the bound it breached. This branch tookinvalid_value— the catalog's declared slot for "rejected for a reason no other member names" — only while no member named a standard-domain breach. The spec half added one. Nothing in the measurement argued against the ruling, so there is nothing to report back on it.Why the message moved: the card asks for the catalog templates, and the sibling door under the same ruling (
record-validator.ts) already renders from them. The door now callsrenderValidationMessagewithvalue_domain_DOMAIN, inen. For anencryptedspecifier the offending value is still never echoed — the catalog template always interpolates the value, so the redaction is now the REST boundary's own mask rather than a truncated sentence, andfields[0].valuestays absent as before:The sentence this produces for a country field is the same one the engine door produces for the same domain (
... must be a valid ISO 3166-1 alpha-2 country code, e.g. CH (got "ZZ")), which is the point of rendering from the catalog rather than composing locally: one ruling, one predicate, one set of words.Both doors are covered. The second door —
effectiveEnvOverride, which refuses anOS_*override — emits a log line, not a FieldError: it has no error code to change, so what it carries is prose, andvalueDomainPhrasingsurvives as its presenter. A pin holds each of its fragments equal to the catalog template for the same domain, so the log line and the wire message cannot drift apart.2 · Equivalence, measured before anything was deleted
Node v22.22.2, the repo's baseline.
iso_3166_alpha2— the two 249-code literals were diffed mechanically before either was deleted (a script that extracts the string literals out of both files and compares): 249 unique each, symmetric difference 0 in both directions, and the ordered sequences are identical. This was the one assumption that could have turned the card into a ruling question; it did not.iso_4217_currency— this one changes definition: a run-timeIntl.supportedValuesOf('currency')probe becomes the key set of the checked-in CLDR snapshotCURRENCY_FRACTION_DIGITS. Measured 162 vs 162, symmetric difference 0 in both directions, positive controlCHFin both, negative controlXYZin neither. So no settings value changes verdict on this runtime; what changes is that the verdict no longer varies with the host's ICU build — the direction the shared module's header argues for. Probe-versus-snapshot as a definition question stays pinned beside the snapshot inpackages/spec/src/shared/value-domain.test.ts; what this package pins is agreement, described in section 9.iana_time_zone— the identicalIntl.DateTimeFormatprobe on both sides, unmoved.knownValueDomainused a hand-writtenhasOwnPropertyguard precisely so'constructor'/'toString'could not be admitted as domains; it now filters through the closed enum's ownsafeParse, which makes the guard structural. The same two names stay pinned across the swap, plus'__proto__'and'hasOwnProperty'.3 · The two instruments
The ratchet (
value-domains.shared-predicate.pin.test.ts) is a source scan over this package's whole non-test source, recursively, in every string delimiter: no membership table in either literal shape, noIntlenumeration probe on an enforcement path, no dense run of two-letter tokens in the door (the alternation shape, which needs no quotes at all), exactly one caller of the predicate, and — the root close —value-domains.tsmay import from@objectstack/spec/sharedand nothing else. Comment stripping routes throughscripts/js-comment-mask.mjs, and that import's radius is declared incross-package-test-inputs.mjs+turbo.json.The agreement pins (
value-domains.test.ts) are behavioural: the door andisValueDomainMembermust answer alike over each domain's whole population — every codeIntl.supportedValuesOfgives for currency (162 here) and time zone (418 here), the published 249 for alpha-2 — plus a corpus for the traps no population contains. Between them, a re-typed table is caught by shape and a re-derived judgement is caught by behaviour.Stated rather than hidden: the original vocabulary-parity pin went vacuous with the re-point, exactly as the card warned —
knownValueDomainnow answers fromValueDomainSchema, so there is nothing left here to diverge from. The equality that still matters is pinned where both sides live (value-domain.test.tsholdsSpecifierValueDomainSchemato beValueDomainSchema, identity not equal members).4 · Assumption audit
iana_time_zonecannot moveiso_4217changes definition, keeps its accept setCHFin both,XYZin neithervalue_domainoccurs zero times in the packagegrep -rn value_domain packages/services/service-settings= 0. The card labels:1025validatePatchand:2060"the second door" — it is the other way round::1025is insideeffectiveEnvOverride(the env door, log line, no code at all) and:2060is insidevalidatePatch(the FieldError). So only one of the two sites had a code to change; the other's before/after is prose. Both are covered aboveknownValueDomain('constructor'/'toString')still null, now viasafeParse; pins extendedvalueDomainPhrasingsurvives, scope narrowed to the env log line, now pinned against the catalogXDRsentence is falseXDRis in both sets;VED/XAU/XAGin neither. The sentence is deleted with the file and is not carried forwardliveness/field.json:228names this cardplannedstatus is correct either way, since it tracks the engine write path5 · Out of scope, and one finding for the reader of #15134
The engine write path on object fields is untouched, and so are
packages/spec/liveness/*— thevalueDomainrow there is contended with the open engine-half PR, so this branch touches no file underpackages/specat all. On #15134 (thepackages/corecopy — afindingawaiting first grading, not touched here): its premise appears already dead onmain.packages/core/src/security/resolve-authz-context.tsimportsisValueDomainMemberfrom@objectstack/spec/sharedat:62and calls it at:1019; the module-privateisValidTimeZoneis gone, withresolve-authz-context.time-zone-domain.pin.test.tsguarding the removal. A repo-wide scan finds no ISO 3166 / ISO 4217 / IANA membership table anywhere underpackages/core/src. Triage's call, not mine.6 · Changeset
minor+ a BREAKING banner. Under the launch-window convention (check-changeset-no-major.mjs)majoris forbidden and the bump level is not the carrier for breaking-ness — the banner and the ADR-0087 disposition are, so both are written. This is not an additive widening of a published surface (the rule that landed inb337a1308); it is a changed refusal code on a published door, which is strictly more than that rule's floor, andminoris the highest level the window allows.7 · Verification
All of it at final commit
6f2f77edf. Earlier heads:46b06feec(pre-review),6dad1547e(after round 1).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived on every head (57 families since round 1, unchanged since). Re-run in full on6f2f77edf: 57 run · 57 exit 0 · 0 red. Two were genuinely red on the way and are fixed in the diff, not worked around:check:comment-mask-adoptionandcheck:cross-package-test-inputs.check:dual-build-cjs-loadsandcheck:type-check-debtanswerexit 3 / PREREQUISITE NOT METuntil the workspace closure exists — read as NOT MEASURED, never as a pass — soturbo run build(71/71 tasks) runs first and both are re-run to a real 0.pnpm --filter @objectstack/service-settings test: 30 files, 533 tests, all passing.pnpm --filter @objectstack/service-settings typecheck(tsc --noEmit): clean, and--listFilesconfirms it covers the 30 test files in this package.dist.8 · Round-1 review — required changes, with the mutations that prove them
Every mutation in this section and the next was written to disk, confirmed on disk by observing the anchor text (never the writer's exit code —
sed/pythonexit 0 on zero hits), and restored under anEXIT/INT/TERMtrap with absolute paths, the restore proven by an emptygit statusand agit hash-objectcomparison against the HEAD blob.src/manifests/zz-alpha2-table.ts.readdirSyncis not recursive andsrc/manifests/exists. Before: green 5/5. After:1 failed | 5 passed. Fixed by walkingsrc/recursively.src/zz-alpha2-array.ts, imported by the door and consulted foriso_3166_alpha2. Before: ratchet green 5/5, door suite green. After:2 failed | 4 passed. Fixed by the array shape regex and, at the root, the import-surface pin.Under M5 the door suite stays green, correctly — a duplicate table with identical membership changes no behaviour. That is the argument for the ratchet being a source scan.
9 · Round-2 review — the delimiter, the alternation, and populations over samples
R1′ — every scan was single-quote-shaped.
from "./zz-alpha2-array.js",["AD", "AE", …]). Before: ratchet green 6/6. The import pin read/\bfrom\s+'([^']+)'/and both shape regexes hard-coded', and this package has noquoteslint rule active, so both spellings are legal here. After:2 failed | 5 passed— the import pin and the array shape./^(?:AD|AE|…|ZW)$/): no import, no quotes, nonew Set(. Before: green. Rather than record it as NOT-covered, it is closed by density: seven or more bare two-uppercase-letter tokens separated by one or two non-alphanumerics, checked on the door — the only place the shape can live now that the import pin holds. After:1 failed | 6 passed.The three scans now read
/\bfrom\s*(['"])([^'"]+)\1/,/(['"])(?:[A-Z]{2} ){7}/and/(?:(['"])[A-Z]{2}\1,\s*){7}/`, and hit 0 of the 31 runtime sources on this head. Still NOT covered, and now stated as such in the file's header: a 3-letter table (a currency list) is not shape-detected in any spelling, and a table reached through a bare package specifier would pass the import pin. Currency is instead reached behaviourally, below.R2′ — the agreement pin covered a corpus while its header claimed it covered "a curated allow-list". Measured false:
iso_4217_currencyanswered by a ten-entry.includeslist, predicate kept for the other two. Before: ratchet green, door suite green, whole package green 529/529 — all four sampled currency members were inside the list. After:1 failed | 17 passed, 152 disagreements (['AED', 'AFN', 'ALL', 'AMD', …(148)]).!v.startsWith('X'), which drops real codes. Before: green. After:1 failed | 17 passed, 7 disagreements (XAF, XCD, XCG, XDR, XOF, XPF, XSU).Both numbers reproduce the review's exactly. The fix uses the host enumeration as the population, never the oracle — each code is put to the door and to the predicate and the two must answer alike — so a code the snapshot lacks is false on both sides, a code the host lacks is never asked, and no ICU build can redden a correct door or hide a divergent one. That is what round 1's objection was about, and it is preserved. Populations on this runtime: currency 162, time zone 418, alpha-2 249. Each pin guards against a vacuous population (
length > 100), and M14/M15 are its live positive controls: the pin is only meaningful because it demonstrably fails.Confirmed and unchanged from round 1:
Object.keys(CURRENCY_FRACTION_DIGITS)is unreachable from this package — the snapshot is declared atcurrency-fraction-digits.ts:51, not re-exported fromdata/index.ts, absent fromapi-surface/, with no wildcard in spec'sexports.Generated by Claude Code