Skip to content

Commit 07a4e26

Browse files
os-zhuangclaude
andauthored
feat(spec)!: field closes, reusing the curated table that already knew which advice would be wrong (#4001) (#4531)
`FieldSchema` carries more silently-stripped keys than any shape in the spec, and it said so about itself for two releases. Two notes on the object — one on `accept`/`maxSize`, one on the five pruned governance keys — both state that a write "parsed clean and the key was silently stripped", and both name it the ADR-0104 failure class. Neither could act, because the object was not strict. The guidance is derived rather than hand-written, and the reason is a bug the first pass shipped. `FIELD_KEY_GUIDANCE` is twenty-odd curated entries for this exact surface, already held honest by a test. A hand-written table beside it is a second copy of the truth, and it proved that immediately: the lint suppresses the suggestion for `pii` BECAUSE `pii` is three edits from `min`, so a bare suggester answers a personally-identifiable-information key with "did you mean `min`?" — confident, wrong, unrelated. The hand-written pass did exactly that. `FieldSchema` now reads the table (`to` → alias, `why` → guidance). The table is unchanged; its consumer moved, which is the intended end state for every entry. The two entries that matter most are the ones that read as protection and were not: `encryptionConfig` and `maskingRule` "implied at-rest protection that never happened". A field declaring either was stored in plaintext exactly as if it had not, silently. The rejection now points at `type: 'secret'` and `requiredPermissions`. A cycle the whole test suite passed through. `suggestions.zod` imports `FieldType` from `field.zod`, so adopting `strictObject` here closed a loop. Under OS_EAGER_SCHEMAS=1 every lazySchema body runs at module init, so the loader hit a half-initialized module and threw before any schema was built. 284 files and 7,239 cases went green over it — tests import lazily, so the cycle never resolved in the order that breaks. Only the eager build caught it. `strictObject` now defers its error map to first use: free, and cycle-proof for every schema after this one instead of making each conversion prove it is not in a loop. Pinned via an alias-table getter that fires exactly when the map is built, and verified to go red when the map is hoisted back to construction. `field` gains its ADR-0010 envelope — it was the ONE type the original probe actually checked, so it was the only gap visible while that probe was green, and it outlasted every gap the probe was hiding. Debt list down to one, from eight. Registered types closed: 22 of 25. Still open: action, dashboard, view. Verified: 284 files / 7240 tests, tsc clean, 8 generated artifacts current, all 10 spec gates green, and CRM/Todo/showcase/platform-objects build. Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7cf42fe commit 07a4e26

9 files changed

Lines changed: 370 additions & 57 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`field` rejects unknown keys, reusing the curated table that already knew which advice would be wrong.
6+
7+
`FieldSchema` carries more silently-stripped keys than any other shape in the spec, and it said so about itself for two releases. Two separate notes on the object — one on `accept`/`maxSize`, one on the five pruned governance keys — both state that a write "parsed clean and the key was silently stripped", and both name it the ADR-0104 failure class. Neither could do anything about it, because the object was not `.strict()`. This is the fix those comments wanted.
8+
9+
**The guidance is derived, not hand-written, and the reason is a bug the first attempt shipped.** `FIELD_KEY_GUIDANCE` in `data/authoring-key-lint.ts` is twenty-odd curated entries for exactly this surface — every one found in the wild, already held honest by a test asserting each `to` names a key `FieldSchema` really declares. A hand-written table beside it would be a second copy of the truth, and it immediately proved why that matters: the lint's table suppresses the suggestion for `pii` **because `pii` is three edits from `min`**. A bare edit-distance suggester answers a personally-identifiable-information key with *"did you mean `min`?"* — confident, wrong, about an unrelated concept. The hand-written pass did exactly that. `FieldSchema` now reads the table directly (`to` → alias, `why` → guidance).
10+
11+
Note what moved: the table is unchanged and still tested. Its *consumer* changed — the lint no longer reaches `field` now that the parse rejects first, so the same curation that powered a warning now powers a rejection. That is the intended end state for every entry in it.
12+
13+
Among what it carries, the two that matter most are the ones that read as protection and were not: `encryptionConfig` and `maskingRule` were pruned in 2026-06 because they "implied at-rest protection that never happened". An author who declared either had their field stored in plaintext exactly as if they had not, and heard nothing. The rejection now points at `type: 'secret'` and at `requiredPermissions` (ADR-0066 D3, enforced by the FieldMasker).
14+
15+
**A cycle the whole test suite passed through.** `shared/suggestions.zod` imports `FieldType` from `data/field.zod`, so adopting `strictObject` here closed a loop — field → strict-object → suggestions → field. Under `OS_EAGER_SCHEMAS=1` (how `build-schemas.ts` runs) every `lazySchema` body executes at module init, so the loader hit a half-initialized module and threw before a single schema was built. **284 test files and 7,239 cases went green over it**; tests import lazily, so the cycle never resolved in the order that breaks. Only the eager build caught it.
16+
17+
`strictObject` now defers its error map to first use, which costs nothing and makes the helper cycle-proof for every schema after this one rather than making each conversion prove it is not in a loop. The property is pinned via an observable — an alias-table getter that fires exactly when the map is built — and verified to go red when the map is hoisted back to construction.
18+
19+
`field` also gains its ADR-0010 protection envelope. It was the one type the original envelope probe actually checked (the other 24 took an early return), so it was the only gap anyone could see for as long as that probe was green — and it outlasted every gap the probe was hiding. **The undeclared-envelope debt list is down to one** (`action`), from eight.
20+
21+
`SelectOptionSchema`, `CurrencyConfigSchema` and the nested shapes under `FieldSchema` (lookup columns, lookup filters, `dependsOn` entries, roll-up summaries) close alongside it. Left open deliberately: `AddressSchema`, `LocationCoordinatesSchema` and `CurrencyValueSchema` are runtime *value* shapes with no consumers at all, two already marked for removal — not authoring surfaces, so strictness is not the question they raise.
22+
23+
Registered types closed at the top level: **22 of 25**. Still open: `action`, `dashboard`, `view`.
24+
25+
Authoring impact: a key `FieldSchema` does not declare is now rejected instead of silently discarded — it was already being ignored, so no working field changes.

content/docs/references/data/field.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,13 @@ const result = Address.parse(data);
130130
| **inlineHelpText** | `string` | optional | Help text displayed below the field in forms |
131131
| **autonumberFormat** | `string` | optional | Auto-number format: literal text + `{0000}` counter, `{YYYY}`/`{MM}`/`{DD}`/`{YYYYMMDD}` date tokens (business tz), and `{field_name}` interpolation. Counter resets per rendered prefix (e.g. AD`{YYYYMMDD}``{0000}` resets daily). |
132132
| **externalId** | `boolean` | optional | Is external ID for upsert operations |
133+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
134+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
135+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
136+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
137+
| **_packageId** | `string` | optional | Owning package machine id. |
138+
| **_packageVersion** | `string` | optional | Owning package version. |
139+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
133140

134141

135142
---

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,50 @@ dropped at parse, and nothing failed.
286286
everyone, and always had been. Closing the shape created the channel, so
287287
both got their sentence.
288288

289+
14. **Closing `field` put `strictObject` inside an import cycle, and the whole
290+
test suite passed through it.** `shared/suggestions.zod` imports `FieldType`
291+
from `data/field.zod`, so the moment `field.zod` adopted the helper the graph
292+
closed a loop: field → strict-object → suggestions → field. Under
293+
`OS_EAGER_SCHEMAS=1` — how `build-schemas.ts` runs — every `lazySchema` body
294+
executes at module init, so whichever module the loader entered first saw a
295+
half-initialized partner and threw `Cannot read properties of undefined
296+
(reading 'strictUnknownKeyError')` before a single schema was built.
297+
298+
**284 test files and 7,239 cases went green over it.** Tests import lazily,
299+
so the cycle never resolved in the order that breaks; only the eager build
300+
hit it. This is finding 9's rule from the other side — there the instrument
301+
reported coverage it did not have, here the instrument was simply the wrong
302+
one, and a green suite meant nothing about the failure mode in question.
303+
304+
Fixed by deferring the error map to first use, which costs nothing (it is
305+
needed only when a key is rejected) and makes the helper cycle-proof for
306+
every schema after this one, instead of making each new conversion prove it
307+
is not in a loop. The property is now pinned in
308+
`shared/strict-object.test.ts` via an observable — an alias-table getter that
309+
fires exactly when the map is built — and verified to go red when the map is
310+
hoisted back to construction time.
311+
312+
15. **`field` carried the campaign's richest curated table, in the wrong layer.**
313+
`FIELD_KEY_GUIDANCE` (in `data/authoring-key-lint.ts`) holds twenty-odd
314+
entries for this one surface — every one found in the wild, held honest by a
315+
test that every `to` names a key `FieldSchema` really declares and that no
316+
entry exists for a key still live.
317+
318+
The first pass at closing `field` hand-wrote a guidance table beside it. That
319+
is a second copy of the truth, and it immediately proved the point: the
320+
lint's table suppresses a suggestion for `pii` **because `pii` is three edits
321+
from `min`**, so a bare edit-distance suggester answers a
322+
personally-identifiable-information key with "did you mean `min`?" —
323+
confident, wrong, about an unrelated concept. The hand-written table did not
324+
know that, and the rejection said exactly that. `FieldSchema` now derives its
325+
aliases and guidance from the table (`to` → alias, `why` → guidance), so the
326+
curation has one home and keeps its existing test.
327+
328+
Worth noting what moved: the table did not change and is not deprecated — its
329+
*consumer* changed. The lint no longer reaches `field` now that the parse
330+
rejects first, so the same curation that used to power a warning now powers a
331+
rejection. That is the intended end state for every entry in it.
332+
289333
This is the empirical argument for the ratchet: the inference "no metadata in
290334
the repo carries unknown keys" was **false three times over**, and only the
291335
strict gate could prove it. Note the asymmetry in the two schema gaps — both

packages/spec/authorable-surface.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3430,6 +3430,13 @@
34303430
"data/ExternalTable:remoteName",
34313431
"data/ExternalTable:remoteSchema",
34323432
"data/ExternalTable:rowCountEstimate",
3433+
"data/Field:_lock",
3434+
"data/Field:_lockDocsUrl",
3435+
"data/Field:_lockReason",
3436+
"data/Field:_lockSource",
3437+
"data/Field:_packageId",
3438+
"data/Field:_packageVersion",
3439+
"data/Field:_provenance",
34333440
"data/Field:accept",
34343441
"data/Field:ackPlaintextMasking",
34353442
"data/Field:allowCreate",

0 commit comments

Comments
 (0)