Skip to content

Commit b7ed26d

Browse files
os-zhuangclaude
andauthored
fix(spec): 不再承诺根本不会跑的校验,并让严格性账本闸门看见嵌套目录(#4001) (#4412)
* fix(spec): stop promising validation that never runs, and let the strictness ledger see nested dirs (#4001) Two corrections to the #4001 unknown-key campaign. Both are the same defect the campaign exists to remove — a success signal covering an omission — found in the campaign's own output. 1. The `config` prescription was false. `DatasourceSchema` went strict in #4207 with `config` left open, and the rejection for a misplaced connection key prescribed: "Move it to `config: { host: … }`; the driver's own configSchema validates it there." Nothing validates it there. `DriverDefinitionSchema.configSchema` is a `z.record`; both bundled driver specs set it to `{}` (mongo's with a comment promising runtime population that no code performs); and no consumer in this repo reads it. So the message took an author who had made a recoverable mistake at a place that now catches it and directed them, with the platform's authority, at a slot where the same mistake is silent again — `config: { hostname: … }` is stripped and the datasource connects on localhost, which is #4001's original bug one level down. The guidance now names the per-driver shape to write against (`PostgresConfigSchema` / `MongoConfigSchema` / `MemoryConfigSchema`) and claims nothing about enforcement. Same false claim removed from `driver/mongo.zod.ts`, whose header advertised that the Platform validates `datasource.config` with it. Enforcement — enforce-or-remove on an inert `configSchema` — is filed as #4410. Error-message and documentation text only; no authorable key changed. 2. The ledger gate's coverage walk was one level deep. `check:strictness-ledger` promises every sited `*.zod.ts` in a triaged directory carries a classification row. It listed directories non-recursively, so `data/driver/` — three per-driver config files, nine authorable sites — was invisible to the check whose entire promise is "no undeclared surface", which printed "no undeclared schema files" and was believed. A gate that under-reports is worse than no gate: it converts "I should classify this" into "it is already classified". The walk is now recursive, the three files are classified, and the ledger records both findings. `scripts/strictness-ledger.test.ts` pins the recursion, and is not redundant with the gate: measured, with the rows now present, reverting to a non-recursive walk leaves the gate GREEN — it cannot catch its own regression. The test can, and was verified to go red before it went green. Verified: spec 283 files / 7205 tests, `tsc --noEmit` clean, all 8 generated artifacts current, and all 10 `check:*` gates green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY * fix(docs): v17 release notes repeated the false driver-configSchema claim (#4001) Fourth and last copy, and the only user-facing one. The v17 notes told readers that `config` and `readReplicas` stay open records because "the driver's own `configSchema` validates them" — the same sentence corrected in `datasource.zod.ts` (module comment + the `belongsInConfig` rejection) and `driver/mongo.zod.ts`. Found by this PR's own docs-drift advisory, which is worth recording: the bot listed 107 docs on a package-level heuristic and the correction was real in exactly one of them. Cheap to check, and it was the copy readers actually see. Now states that nothing validates inside `config`, that the per-driver schemas exist but parse nothing, and that a misspelling one level down is still silent — with the pointer to #4410. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 302e972 commit b7ed26d

8 files changed

Lines changed: 248 additions & 26 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Datasource unknown-key guidance no longer promises validation that does not happen, and the #4001 strictness ledger sees nested schema directories.
6+
7+
Two related corrections to the #4001 unknown-key campaign, both about a check reporting more coverage than it had.
8+
9+
**The `config` prescription was false.** When `DatasourceSchema` rejects a connection detail written at the top level, it prescribed: "Move it to `config: { host: … }`; the driver's own configSchema validates it there." Nothing validates it there — `DriverDefinitionSchema.configSchema` is a `z.record` that both bundled driver specs set to `{}`, and no consumer reads it. The message therefore took an author who had made a recoverable mistake at a place that now catches it and pointed them at a slot where the same mistake is silent again: `config: { hostname: … }` is dropped and the datasource connects on localhost. The guidance now names the per-driver shape to write against (`PostgresConfigSchema` / `MongoConfigSchema` / `MemoryConfigSchema`) instead of promising a gate. Enforcement is tracked in #4410. The same false claim is removed from `data/driver/mongo.zod.ts`, whose header advertised that the Platform validates `datasource.config` with it.
10+
11+
No authorable key changed — this is error-message and documentation text only.
12+
13+
**The ledger gate's coverage walk was one level deep.** `check:strictness-ledger` promises that every `*.zod.ts` with sites in a triaged directory carries a classification row. It listed each directory non-recursively, so `data/driver/` — three per-driver config files, nine authorable sites — was invisible to it while it printed "no undeclared schema files". The walk is now recursive (nested files declare as `driver/postgres.zod.ts`), those three files are classified in the ledger, and `scripts/strictness-ledger.test.ts` pins the recursion — necessary because with the rows in place the gate itself passes either way and cannot catch its own regression.

content/docs/releases/v17.mdx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -412,8 +412,12 @@ schema to the two highest-risk authorable surfaces, per the triage in
412412
- **Datasources**`DatasourceSchema` with its `pool` / `healthCheck` / `ssl` /
413413
`retryPolicy` blocks, the ADR-0015 `external` federation settings and their
414414
`validation` policy, `DatasourceCapabilities`, and `DriverDefinitionSchema`.
415-
`config` and `readReplicas` stay **open** records: their shape is per-driver
416-
and the driver's own `configSchema` validates them. That openness is why the
415+
`config` and `readReplicas` stay **open** records: their shape is per-driver.
416+
Nothing validates *inside* them — an earlier version of this note said the
417+
driver's own `configSchema` did, which was wrong; the per-driver schemas exist
418+
(`PostgresConfigSchema` and siblings) but nothing parses `config` against
419+
them, tracked as #4410. So a misspelling one level *down* is still silent
420+
today. That openness is why the
417421
top level had to close — a connection key written one level too high (`host`
418422
next to `driver` instead of inside `config`) was stripped, and the datasource
419423
then connected on driver defaults rather than failing. Those keys now

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

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,18 @@ dropped at parse, and nothing failed.
101101
now aliases to `expanded`. Note where this one was found: not in a tenant
102102
project, but in first-party platform metadata that had been shipping for
103103
releases.
104+
7. **This campaign's own fix signposted the way into the failure mode it
105+
exists to kill.** The strict rejection on a misplaced `host` prescribed:
106+
"Move it to `config: { host: … }`; the driver's own configSchema validates
107+
it there." False twice over — `DriverDefinitionSchema.configSchema` is a
108+
`z.record` that both bundled driver specs set to `{}`, and nothing in the
109+
repo reads it. So an author who made a *recoverable* mistake at a place that
110+
now catches it was directed, with the platform's authority, at a slot where
111+
the same mistake is silent again: `config: { hostname: … }` is stripped and
112+
the datasource connects on localhost — #4001's original bug verbatim, one
113+
level down. Corrected to name the per-driver shape instead of promising a
114+
gate; enforcement is #4410. **A wrong instruction is worse than none**, and
115+
worst for an AI author, whose only signal is whether the parse complained.
104116

105117
This is the empirical argument for the ratchet: the inference "no metadata in
106118
the repo carries unknown keys" was **false three times over**, and only the
@@ -146,7 +158,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
146158
| `notification.zod.ts` / `offline.zod.ts` / `report.zod.ts` | 3 ea | authorable (p) | |
147159
| `sharing.zod.ts` | 2 | authorable (p) | public-sharing config |
148160

149-
### `data/`149 sites
161+
### `data/`158 sites
150162

151163
| File | Sites | Class | Note |
152164
|---|---|---|---|
@@ -157,7 +169,8 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
157169
| `field.zod.ts` | 11 | authorable | partially strict |
158170
| `filter.zod.ts` / `query.zod.ts` | 11+5 | open | query dialect — user data flows through; validated semantically elsewhere. `query.zod.ts` dropped one site in #4196: `FieldNodeSchema`'s nested-select object form was declared-but-inert and narrowed to `z.string()`, so the union's second member is gone. Four more left in #4286 with the `joins`/`windowFunctions` removals: `JoinNodeBaseSchema`, `WindowFunctionNodeSchema`, and `WindowSpecSchema`'s two blocks (outer + `frame`) were deleted with their clusters. Class unchanged |
159171
| `driver-nosql.zod.ts` / `driver.zod.ts` / `driver-sql.zod.ts` | 10+9+2 | wire | driver capability contracts |
160-
| `datasource.zod.ts` | 9 | authorable | **strict as of #4001 data step** — all 9: `DatasourceSchema` (+ `pool` / `healthCheck` / `ssl` / `retryPolicy`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DatasourceCapabilities`, `DriverDefinitionSchema`. `config` + `readReplicas` stay `z.record` by construction (per-driver shapes; the driver's own `configSchema` validates them) — which is precisely why the top level had to close: a connection key written one level too high was stripped, and the datasource then connected on driver defaults instead of failing |
172+
| `datasource.zod.ts` | 9 | authorable | **strict as of #4001 data step** — all 9: `DatasourceSchema` (+ `pool` / `healthCheck` / `ssl` / `retryPolicy`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DatasourceCapabilities`, `DriverDefinitionSchema`. `config` + `readReplicas` stay `z.record` by construction (per-driver shapes — see the `driver/` row below). This row used to add "the driver's own `configSchema` validates them"; **it does not, and never did** — corrected, and the gap is #4410. Which is precisely why the top level had to close: a connection key written one level too high was stripped, and the datasource then connected on driver defaults instead of failing |
173+
| `driver/memory.zod.ts` / `driver/mongo.zod.ts` / `driver/postgres.zod.ts` | 6+1+2 | authorable | The per-driver shapes for the `config` slot — what an author actually writes under `datasource.config` (`host`, `port`, `filename`, pool sizes). **Undeclared here until the coverage walk went recursive** (see below): a subdirectory was invisible to the gate, so these nine sites sat outside the map while the map reported full coverage. Authorable by the rule, but they are **contract-only exports today** — nothing parses `datasource.config` against them and both `*DriverSpec.configSchema` literals are `{}` (#4410). Strictness here would therefore enforce nothing; this row is blocked on #4410 giving it a parse site, not on a verification pass |
161174
| `analytics.zod.ts` | 8 | mixed (p) | |
162175
| `document.zod.ts` | 8 | wire (p) | |
163176
| `hook.zod.ts` / `hook-body.zod.ts` | 6+2 | mixed | **strict as of #4001 data step** for the AUTHORING shapes: `HookSchema` (+ `retryPolicy`) and both body branches (`ExpressionBodySchema` / `ScriptBodySchema`). `HookContextSchema` and its `session` / `provenance` / `user` blocks are the RUNTIME shape the engine hands a handler — they stay tolerant, and must: strictness there would make an engine-internal enrichment (as `provenance` was in #3712) a breaking change for anyone parsing a context they were given. The file's old blanket `authorable (p)` was too wide — verification split it |
@@ -220,6 +233,32 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
220233
strict close-out on what it actually reports — which is the whole point of
221234
having built it. Nothing more to do here until there is field data.
222235

236+
**This wait has a decision point, deliberately.** "Wait for field data" with
237+
no way to tell when it has arrived is how a ratchet stops without anyone
238+
choosing to stop it — and this file would go on describing an in-flight
239+
campaign either way. So the wait is discharged by an answerable question, not
240+
by a date: *has `lintUnknownAuthoringKeys` reported an unknown key on any
241+
surface outside this repo yet?* Three outcomes, each with a next action:
242+
- **Findings exist** → they are the close-out worklist. Tighten the shapes
243+
they name first; that is the evidence the whole layer was built to produce.
244+
- **Zero findings, and the layer is reaching real authors** → the tail is
245+
cheaper than feared and the remaining directories can be batched by class
246+
rather than one shape at a time.
247+
- **Zero findings because nothing is reporting back** → then the layer is not
248+
instrumented, and *that* is the next task, not more strictness. This is the
249+
outcome to actually check for: it is indistinguishable from success at a
250+
glance, which is this campaign's own subject matter.
251+
252+
Whoever reads this next: answer the question and record the answer here, even
253+
if the answer is "still nothing". A wait that is never re-examined is
254+
indistinguishable from an abandoned one.
255+
256+
2. `studio/` is the largest untouched authorable block — 27 sites, **0 strict**,
257+
and all three files still carry a provisional `(p)` from the original triage.
258+
Not blocked on field data (Studio-written JSON is our own producer, so the
259+
downstream risk is the lowest on the board); it is simply unstarted. If the
260+
step-1 question comes back "nothing is reporting", start here instead.
261+
223262
Done in step 2: `security/rls.zod.ts` + `security/sharing.zod.ts` strict;
224263
`PositionSchema` strict with the protection envelope declared (closing the
225264
known sibling gap below).
@@ -303,9 +342,11 @@ checkable, so this map cannot go stale in silence again:
303342
matches means schemas were added or removed under a `Class` verdict nobody
304343
re-examined. Touching a file forces you back through this ledger.
305344
- **Coverage.** Every `*.zod.ts` in a triaged directory that HAS sites must have
306-
a row. A new one is undeclared surface. Zero-site files (pure enum/token
307-
modules like `data/date-macros.zod.ts`) are skipped — there is nothing to
308-
classify — and become reportable the day they grow their first `z.object(`.
345+
a row. A new one is undeclared surface. The walk is **recursive**; nested files
346+
are declared by their path relative to the section directory
347+
(`driver/postgres.zod.ts`). Zero-site files (pure enum/token modules like
348+
`data/date-macros.zod.ts`) are skipped — there is nothing to classify — and
349+
become reportable the day they grow their first `z.object(`.
309350
- **Section totals**, and that any row claiming "strict as of" names a file that
310351
really contains `.strict()`.
311352

@@ -336,5 +377,23 @@ precisely the intended behaviour: the file arrived, so someone had to classify
336377
it. It is now a row. Every existing count survived that merge unchanged, so the
337378
failure was exactly as narrow as it should have been.
338379

380+
**And then the gate turned out to have the ledger's own disease.** Its coverage
381+
walk listed each triaged directory exactly one level deep, so `data/driver/`
382+
three per-driver connection-config files, nine sites — was invisible to the check
383+
whose entire promise is "no undeclared surface". The gate printed *"no undeclared
384+
schema files"* while nine authorable sites sat outside the map. Fixed by making
385+
the walk recursive; the three files are now a row.
386+
387+
Read that next to this file's own opening argument — *a map that drifts is worse
388+
than no map, because it is followed*. The same asymmetry applies one level up,
389+
and harder: **a gate that under-reports is worse than no gate, because it
390+
converts "I should classify this" into "it is already classified."** No gate
391+
leaves a reader suspicious; a green gate retires their suspicion. That is the
392+
identical shape to the silent strip this whole campaign is about — a success
393+
signal covering an omission — reproduced in the instrument built to detect it.
394+
So when a check claims coverage, prove it sees something it is supposed to see
395+
before trusting the green: this one was verified by watching it go red on
396+
`data/driver/` and green again only once the rows existed.
397+
339398
Long tail stays gated on a verification pass per shape — never a one-shot
340-
"make all ~453 sites strict" (ADR-0054 ratchet; #4001's own recommendation).
399+
"make all ~500 sites strict" (ADR-0054 ratchet; #4001's own recommendation).

packages/spec/scripts/check-strictness-ledger.mts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,14 @@
2323
// touching a file forces you back through the ledger.
2424
// 2. Coverage. Every `*.zod.ts` under a triaged directory that HAS `z.object(` sites
2525
// must appear in that directory's table. A new one is undeclared surface —
26-
// exactly what the ledger exists to prevent. Files with zero sites (pure enum /
27-
// token modules like `data/date-macros.zod.ts`) are skipped: the ledger classifies
28-
// sites, and they have none to classify. This is not a hole — the day such a file
29-
// grows its first `z.object(` it becomes undeclared and this gate says so.
26+
// exactly what the ledger exists to prevent. The walk is RECURSIVE; nested files
27+
// are declared by their path relative to the section directory
28+
// (`driver/postgres.zod.ts`). It was not recursive at first, and `data/driver/`
29+
// sat undeclared behind that — see the ledger's note on it. Files with zero sites
30+
// (pure enum / token modules like `data/date-macros.zod.ts`) are skipped: the
31+
// ledger classifies sites, and they have none to classify. This is not a hole —
32+
// the day such a file grows its first `z.object(` it becomes undeclared and this
33+
// gate says so.
3034
// 3. Section totals. `### \`ui/\` — 192 sites` must equal the sum of its rows.
3135
// Cheap, and it catches a row edited without updating the header.
3236
// 4. Strictness claims. A row whose note says "strict as of" must name a file that
@@ -48,6 +52,8 @@ import fs from 'node:fs';
4852
import path from 'node:path';
4953
import url from 'node:url';
5054

55+
import { countSites, listSchemaFiles } from './lib/strictness-ledger';
56+
5157
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
5258
const SPEC = path.resolve(HERE, '..');
5359
const REPO = path.resolve(SPEC, '../..');
@@ -79,11 +85,6 @@ function parseCounts(cell: string, fileCount: number): number[] | null {
7985
return nums.length === fileCount ? nums : null;
8086
}
8187

82-
/** `z.object(` occurrences — the ledger's own stated counting method. */
83-
function countSites(file: string): number {
84-
return (fs.readFileSync(file, 'utf-8').match(/z\.object\(/g) ?? []).length;
85-
}
86-
8788
const md = fs.readFileSync(LEDGER, 'utf-8').split('\n');
8889
const rows: Row[] = [];
8990
const sectionTotals = new Map<string, { declared: number; line: number }>();
@@ -169,7 +170,10 @@ for (const row of rows) {
169170
for (const [d, declared] of declaredByDir) {
170171
const dirPath = path.join(SRC, d);
171172
if (!fs.existsSync(dirPath)) continue;
172-
const onDisk = fs.readdirSync(dirPath).filter((f) => f.endsWith('.zod.ts'));
173+
// Recursive — see lib/strictness-ledger.ts for why that is load-bearing.
174+
// Nested files are declared by their path relative to the section directory
175+
// (`driver/postgres.zod.ts`), which the row parser already accepts.
176+
const onDisk = listSchemaFiles(dirPath);
173177
// Zero-site files carry nothing to classify (see the header note). They become
174178
// reportable the moment they grow a `z.object(`.
175179
const missing = onDisk.filter((f) => !declared.has(f) && countSites(path.join(dirPath, f)) > 0);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Shared pieces of the #4001 strictness-ledger gate
5+
* (`../check-strictness-ledger.mts`), extracted so the gate and its regression
6+
* test cannot drift apart on the one property that already broke silently:
7+
* whether the coverage walk descends into subdirectories.
8+
*/
9+
10+
import fs from 'node:fs';
11+
import path from 'node:path';
12+
13+
/** `z.object(` occurrences — the ledger's own stated counting method. */
14+
export function countSites(file: string): number {
15+
return (fs.readFileSync(file, 'utf-8').match(/z\.object\(/g) ?? []).length;
16+
}
17+
18+
/**
19+
* Every `*.zod.ts` under `dir`, **recursively**, as `/`-separated paths relative
20+
* to `dir` (so a nested file reads `driver/postgres.zod.ts` — exactly how the
21+
* ledger declares it).
22+
*
23+
* The recursion is the whole point of this function existing. The gate's first
24+
* version listed each triaged directory one level deep, which made
25+
* `data/driver/` — three per-driver connection-config files, nine authorable
26+
* sites — invisible to the check whose entire promise is "no undeclared
27+
* surface". It printed "no undeclared schema files" and was believed.
28+
*
29+
* A gate that under-reports is worse than no gate: it converts "I should
30+
* classify this" into "it is already classified". Keep the walk recursive, and
31+
* see `strictness-ledger.test.ts`, which fails if it stops being.
32+
*/
33+
export function listSchemaFiles(dir: string): string[] {
34+
return fs
35+
.readdirSync(dir, { recursive: true })
36+
.map((f) => String(f).split(path.sep).join('/'))
37+
.filter((f) => f.endsWith('.zod.ts'))
38+
.sort();
39+
}

0 commit comments

Comments
 (0)