Skip to content

Commit ed63e0d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-18983-connector-tsdoc-declared-unimplemented
2 parents c16ab28 + 75c0dac commit ed63e0d

15 files changed

Lines changed: 650 additions & 110 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
**BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states the cert/key pairing rule on SSL driver configuration, so a validator reading the published files stops answering PASS on a half-configured client certificate the platform then refuses (#18670 item 2, the third of the ruling's four named arms).
6+
7+
Clause-②: yes (narrowing)
8+
9+
One named pattern joins the closed list, and only one:
10+
11+
- **`dependentRequired` — "whenever this key is present, those keys must be present too"**, emitted as JSON Schema's own `dependentRequired`. `SSLConfig`'s rule that a client certificate and its private key are provided together is precisely `dependentRequired { cert: ['key'], key: ['cert'] }`, so the file now states it.
12+
13+
**The rows retired, by name.** `packages/spec/dropped-refinements.baseline.json` goes from 201 entries / 553 sites to **200 entries / 551 sites**:
14+
15+
| row | before | after |
16+
|:---|:---|:---|
17+
| `data/SSLConfig` | `sites: [""]` | **deleted** — the schema drops nothing now |
18+
| `data/SQLDriverConfig` | `sites: ["", "sslConfig"]` | `sites: [""]` — the `sslConfig` site closed |
19+
20+
2 sites closed, **0 sites added anywhere**, and the ledger diff is deletions only. `data/SQLDriverConfig`'s remaining `""` site is its own separate rule — "`sslConfig` is required when `ssl` is **true**" — which judges a VALUE rather than key presence, is `if`/`then` rather than this arm, and stays dropped and annotated as `x-dropped-refinements`.
21+
22+
**⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** The arm is EXACT rather than approximate: a key absent from a JSON object is the only way for its value to read `undefined`, and `dependentRequired` triggers on presence, so a key present with any JSON value — `null` included — arms its dependency exactly as the predicate's `!== undefined` does. Measured over a 10,368-document corpus across both affected schemas: the runtime verdict vector is byte-identical before and after (lit control — weakening the dependency map to one direction moves 96 documents), and of the 36 documents the published files stop accepting, **zero** are documents the runtime accepts. Across the whole published tree, 1530 of 1532 files are byte-identical; the two that move gain `dependentRequired` and lose the matching `x-dropped-refinements` row.
23+
24+
**The list stays CLOSED.** `packages/spec/src/shared/refinement-projection.ts` declares the vocabulary and builds each predicate from its own declaration — the dependency map is read once and used by both the published keyword and the enforced rule — so the two cannot name different keys. A refinement outside the list stays unprojected and keeps its annotation. `propertyNames` / `not` for banned keys remains untaken: the tree carries no candidate whose rule is mechanically derivable, so no arm was constructed for it.
25+
26+
**Two mechanism repairs ship with it**, both invisible in the published output and both load-bearing from this arm onward. The detector's verdict was reached per NODE while refinements are per CHECK, so a node carrying a declared arm beside an undeclared rule read `projected` outright and the undeclared rule reached neither the ledger nor the annotation; `projected` now requires every check on the node to be declared, and the generator reports partially-stated sites on their own line. And the generator and the detector each passed the projection `override` for themselves — dropping it on the generator side alone left every site reading `projected` behind a green ledger while the published file silently went wide — so both now reach `z.toJSONSchema` through one shared call with no argument left to forget.
27+
28+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author can write is removed, renamed or re-spelled: no spec key, no export and no config field changes, and the accepted set of metadata documents is byte-for-byte what it was. What changed is a machine-readable DECLARATION catching up with the runtime it always described, so there is nothing for `objectstack migrate meta` to rewrite and no stored representation to convert. -->
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(data): `ResolveApiOptions.userExportAllowed` no longer documents itself as "always `true` this phase" — the user-level export bit is wired, and it is a real opt-in grant that can be `false` (#18991)
6+
7+
`Clause-②: no`
8+
9+
⛔ **No behaviour change.** `isLegacyDerivable`, `computeOperations` and `resolveEffectiveApiMethods` are byte-identical; the omitted-option default is still `true` (`opts?.userExportAllowed !== false`), and not one assertion in `api-derivation.test.ts` moved. What changes is two docblocks in `packages/spec/src/data/api-derivation.ts` that made a **false present-tense claim**, and the generated declaration baseline that reproduces one of them.
10+
11+
Both carriers said the same untrue thing, and they said it in a direction that invites reintroducing a defect:
12+
13+
- `ResolveApiOptions.userExportAllowed` — "Always `true` this phase (there is no user-level export permission bit yet); wiring a real bit in is a zero-contract change".
14+
- the `API_METHOD_DERIVATION` table docblock — "`export` is `list`, additionally gated by the user-level export slot (…, always `true` this phase — the real permission bit is a follow-up, wiring it changes no contract here)".
15+
16+
The bit exists. `PermissionSetSchema.allowExport` (`src/security/permission.zod.ts`) declares the user-level export axis as an **opt-in grant** — `true` grants export, UNSET or `false` means no export — and the two statements cannot both be true. It is not an aspiration either: `plugin-security`'s `permission-evaluator` resolves `export` as `list ∧ userExportAllowed` and returns `false` from that branch, `plugin-hono-server`'s `/me/permissions` computes the bit and hands it to `resolveEffectiveApiMethods`, and this package's own suite has pinned the `false` arm all along (`export gated off when userExportAllowed=false`).
17+
18+
An author who trusted the old text would read the parameter as inert and could legitimately simplify it away as dead weight — which is the same defect one level upstream of where it was last found, with no consumer left to notice. Both docblocks now state the axis as it is, name `PermissionSetSchema`'s `allowExport` as the authority on its semantics, and keep the one thing that *is* still true distinct from the one that is not: omitting the option resolves to `true` because a resolve carrying no permission context must not narrow the object's own exposure — that is what lets `apiExposureDenialReason` remain a pure function of `enable` — while a caller holding permission context passes the resolved bit explicitly.
19+
20+
**Why this publishes rather than taking `skip-changeset`.** Two entries of this package's `files[]` move. `api-surface-declarations/` ships, and the member docblock sits *inside* the `ResolveApiOptions` interface body, so it is part of the declaration text that artifact records (leading TSDoc is excluded; an interior member's is not) — `check:api-surface-declarations` reported the shard stale as `~ ResolveApiOptions (interface) (declaration text changed)`, 0 removed, 0 added, 1 reshaped, and the regenerated `data.txt` carries the new text. `dist/` ships too, and the packed `dist/data/index.d.ts` carries it. A consumer reading either one reads different bytes after this change, so the corrected sentence is what reaches them.

‎packages/spec/api-surface-declarations/data.txt‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21410,8 +21410,19 @@ declare const ReplicationConfigSchema: z.ZodObject<{
2141021410
interface ResolveApiOptions {
2141121411
/**
2141221412
* User-level export permission slot. `export` derives from `list` AND this
21413-
* flag. Always `true` this phase (there is no user-level export permission
21414-
* bit yet); wiring a real bit in is a zero-contract change (#3391 follow-up).
21413+
* flag.
21414+
*
21415+
* The flag carries the user-level export axis — `PermissionSetSchema`'s
21416+
* `allowExport` bit (`../security/permission.zod`, the authority on its
21417+
* semantics). That bit is an OPT-IN GRANT: unset or `false` means NO export.
21418+
* So this flag is genuinely `false` for a real caller whose permission sets
21419+
* withhold the grant, and `export` is withheld with it (#3391 / #3544).
21420+
*
21421+
* Omitting the option is the NO-USER-CONTEXT case and resolves to `true` —
21422+
* a resolve that carries no permissions does not narrow the object's own
21423+
* exposure, which is what lets {@link apiExposureDenialReason} stay a pure
21424+
* function of `enable`. A caller that HAS permission context passes the
21425+
* resolved bit explicitly.
2141521426
*/
2141621427
userExportAllowed?: boolean;
2141721428
}

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
"description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.",
33
"measured": {
44
"zod": "4.4.3",
5-
"publishedSchemasWithDroppedRefinements": 201,
6-
"droppedRefinementSites": 553,
7-
"refinementSitesThatDidProject": 197,
5+
"publishedSchemasWithDroppedRefinements": 200,
6+
"droppedRefinementSites": 551,
7+
"refinementSitesThatDidProject": 199,
88
"refinementSitesWithNoJsonFormToCompare": 3
99
},
1010
"entries": {
@@ -738,12 +738,6 @@
738738
]
739739
},
740740
"data/SQLDriverConfig": {
741-
"sites": [
742-
"",
743-
"sslConfig"
744-
]
745-
},
746-
"data/SSLConfig": {
747741
"sites": [
748742
""
749743
]

‎packages/spec/scripts/build-schemas.ts‎

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import {
4949
// The ratchet below measures against this same override, so a rule the list
5050
// emits leaves the ledger and a rule it does not emit stays in it — see the
5151
// module header for why the two halves must not be read against each other.
52-
import { refinementProjectionOverride } from './lib/refinement-projection';
52+
import { projectPublishedJsonSchema } from './lib/refinement-projection';
5353
// The dropped-refinement ratchet (#18670). The mirror image of the branch
5454
// pruning above, and deliberately its own module for the same reason: the
5555
// pruner guards a projection NARROWER than the Zod type, this one the direction
@@ -498,19 +498,12 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) {
498498
let io: 'output' | 'input' = 'output';
499499
let prunedBranches: readonly PrunedBranch[] = [];
500500
try {
501-
jsonSchema = z.toJSONSchema(value, {
502-
target: 'draft-2020-12',
503-
override: refinementProjectionOverride,
504-
}) as Record<string, unknown>;
501+
jsonSchema = projectPublishedJsonSchema(value) as Record<string, unknown>;
505502
} catch (outputError) {
506503
if (!isKnownUnsupported(outputError)) throw outputError;
507504
io = 'input';
508505
try {
509-
jsonSchema = z.toJSONSchema(value, {
510-
target: 'draft-2020-12',
511-
io: 'input',
512-
override: refinementProjectionOverride,
513-
}) as Record<string, unknown>;
506+
jsonSchema = projectPublishedJsonSchema(value, { io: 'input' }) as Record<string, unknown>;
514507
} catch (inputError) {
515508
if (!isKnownUnsupported(inputError)) throw inputError;
516509
// THIRD attempt, #16431 (a): both directions above refuse the
@@ -526,10 +519,7 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) {
526519
// then re-thrown with the message Zod produced, so this attempt
527520
// can never change WHY an export is skipped, and so never the
528521
// `cause` recorded for it in unemitted-schemas.baseline.json.
529-
const projected = projectByPruningUnionBranches(value, {
530-
target: 'draft-2020-12',
531-
override: refinementProjectionOverride,
532-
});
522+
const projected = projectByPruningUnionBranches(value);
533523
if (!projected) throw inputError;
534524
jsonSchema = projected.schema;
535525
io = projected.io;
@@ -3603,6 +3593,41 @@ if (projectedSiteTotal > 0) {
36033593
}
36043594
}
36053595

3596+
// Nodes whose projection MOVED and which are still counted as dropped — the
3597+
// reading the per-node differential cannot express as a verdict (#18670 third
3598+
// arm). Two shapes reach this line and both are news:
3599+
//
3600+
// - a node carrying a DECLARED arm beside a rule the closed list does not
3601+
// cover, so part of it is stated in the file and part of it is not. It is
3602+
// ledgered and annotated conservatively, which is what the ruling's 「A
3603+
// refinement that is not one of these named patterns stays dropped and
3604+
// annotated」 requires — before the verdict was per-check-aware such a node
3605+
// read `projected` outright and its undeclared rule was recorded nowhere;
3606+
// - zod having started to project a `custom` check on its own, which is the
3607+
// upgrade this whole instrument is waiting for and must not swallow.
3608+
//
3609+
// Printed rather than fatal: the site is already held by the ledger as a drop,
3610+
// so a NEW one fails the ratchet above on its own. What this line adds is WHICH
3611+
// of the declared population is only half-stated, which no count can say.
3612+
const partiallyStated = refinementCensus.flatMap((entry) =>
3613+
entry.dropped
3614+
.filter((site) => site.projectionMoved)
3615+
.map((site) => ({ defKey: entry.defKey, site })),
3616+
);
3617+
if (partiallyStated.length > 0) {
3618+
console.log(
3619+
`\n🪢 ${partiallyStated.length} refinement site(s) are PARTIALLY stated by the published file — ` +
3620+
`the projection moved, yet not every \`custom\` check on the node is one the closed list declares, ` +
3621+
`so the node stays dropped and annotated (#18670).`,
3622+
);
3623+
for (const { defKey, site } of partiallyStated) {
3624+
const declared = site.declaredPatterns.length > 0
3625+
? site.declaredPatterns.join('+')
3626+
: 'nothing declared — zod projected this on its own';
3627+
console.log(` ${defKey} at "${site.path}": ${site.count} check(s), declared: ${declared}`);
3628+
}
3629+
}
3630+
36063631
// ─── Generate Bundled Schema ─────────────────────────────────────────
36073632
// Single-file bundled schema containing all generated schemas for IDE autocomplete
36083633

‎packages/spec/scripts/dropped-refinements.test.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,15 @@ describe('the differential isolates the refinement, not the node', () => {
225225
});
226226

227227
describe('the ratchet adjudicates against the ledger', () => {
228-
const site = (path: string) => ({ path, nodeType: 'string', count: 1, aborting: false, verdict: 'dropped' as const, declaredPatterns: [] });
228+
const site = (path: string) => ({
229+
path,
230+
nodeType: 'string',
231+
count: 1,
232+
aborting: false,
233+
verdict: 'dropped' as const,
234+
declaredPatterns: [],
235+
projectionMoved: false,
236+
});
229237
const census = (defKey: string, paths: string[]) => ({
230238
defKey,
231239
dropped: paths.map(site),

0 commit comments

Comments
 (0)