Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/field-strict-guidance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/spec': minor
---

`field` rejects unknown keys, reusing the curated table that already knew which advice would be wrong.

`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.

**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).

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.

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).

**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.

`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.

`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.

`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.

Registered types closed at the top level: **22 of 25**. Still open: `action`, `dashboard`, `view`.

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.
7 changes: 7 additions & 0 deletions content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ const result = Address.parse(data);
| **inlineHelpText** | `string` | optional | Help text displayed below the field in forms |
| **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). |
| **externalId** | `boolean` | optional | Is external ID for upsert operations |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
| **_packageId** | `string` | optional | Owning package machine id. |
| **_packageVersion** | `string` | optional | Owning package version. |
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |


---
Expand Down
44 changes: 44 additions & 0 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,50 @@ dropped at parse, and nothing failed.
everyone, and always had been. Closing the shape created the channel, so
both got their sentence.

14. **Closing `field` put `strictObject` inside an import cycle, and the whole
test suite passed through it.** `shared/suggestions.zod` imports `FieldType`
from `data/field.zod`, so the moment `field.zod` adopted the helper the graph
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 whichever module the loader entered first saw a
half-initialized partner and threw `Cannot read properties of undefined
(reading 'strictUnknownKeyError')` 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
hit it. This is finding 9's rule from the other side — there the instrument
reported coverage it did not have, here the instrument was simply the wrong
one, and a green suite meant nothing about the failure mode in question.

Fixed by deferring the error map to first use, which costs nothing (it is
needed only when a key is rejected) and makes the helper cycle-proof for
every schema after this one, instead of making each new conversion prove it
is not in a loop. The property is now pinned in
`shared/strict-object.test.ts` 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 time.

15. **`field` carried the campaign's richest curated table, in the wrong layer.**
`FIELD_KEY_GUIDANCE` (in `data/authoring-key-lint.ts`) holds twenty-odd
entries for this one surface — every one found in the wild, held honest by a
test that every `to` names a key `FieldSchema` really declares and that no
entry exists for a key still live.

The first pass at closing `field` hand-wrote a guidance table beside it. That
is a second copy of the truth, and it immediately proved the point: the
lint's table suppresses a suggestion for `pii` **because `pii` is three edits
from `min`**, so 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 table did not
know that, and the rejection said exactly that. `FieldSchema` now derives its
aliases and guidance from the table (`to` → alias, `why` → guidance), so the
curation has one home and keeps its existing test.

Worth noting what moved: the table did not change and is not deprecated — its
*consumer* changed. The lint no longer reaches `field` now that the parse
rejects first, so the same curation that used to power a warning now powers a
rejection. That is the intended end state for every entry in it.

This is the empirical argument for the ratchet: the inference "no metadata in
the repo carries unknown keys" was **false three times over**, and only the
strict gate could prove it. Note the asymmetry in the two schema gaps — both
Expand Down
7 changes: 7 additions & 0 deletions packages/spec/authorable-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -3430,6 +3430,13 @@
"data/ExternalTable:remoteName",
"data/ExternalTable:remoteSchema",
"data/ExternalTable:rowCountEstimate",
"data/Field:_lock",
"data/Field:_lockDocsUrl",
"data/Field:_lockReason",
"data/Field:_lockSource",
"data/Field:_packageId",
"data/Field:_packageVersion",
"data/Field:_provenance",
"data/Field:accept",
"data/Field:ackPlaintextMasking",
"data/Field:allowCreate",
Expand Down
Loading
Loading