Skip to content

Commit f5e13cb

Browse files
committed
merge: origin/main into the card-trailer branch
2 parents 5e3eb8f + 396eae3 commit f5e13cb

9 files changed

Lines changed: 379 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+
Scope the text-operator declared-type door's `formula` prose to the judgement it
6+
actually states. The module declared that a `formula` with a readable
7+
`returnType` is judged as the field type its return type names, but at the
8+
door's only consumer — the engine's field-aware seam — a filter over a formula
9+
field never arrives: the earlier materializability door refuses every one of
10+
them with `INVALID_FIELD` 400, whatever the `returnType`. The verdict function,
11+
its sets, the class table and every case are unchanged; only the prose now says
12+
the formula rows are a contract answer no consumer currently reaches, and why
13+
they are kept rather than retired.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): state the retired `allowRestore` / `allowPurge` parse-time accept set exactly (#17425)
6+
7+
Documentation only — no schema, no key, no exported symbol and no accepted value moves. What changes is what the tombstone's own prose claims about itself, in the three places a consumer reads it: the `permission.zod.ts` docblocks (published in the tarball, both as `dist/*.d.ts` and as the `src/**/*.zod.ts` sources this package ships), and the two hand-written permission docs pages.
8+
9+
The prose said the retired bits are refused, and separately that "every other value" lands on the tombstone. Read together those two sentences describe a truthy/falsy split, and that is not what the schema does. Measured on this tree, `ObjectPermissionSchema` tolerates exactly ONE value: the boolean literal `false` the published 17.x toolchain materialized into every permission entry of every artifact it built, accepted as inert residue and silently stripped under the retired-defaulted-key class rule. Every other value of any type — including the string `"false"`, the number `0` and `null` — is refused exactly like `true`, with `code: 'invalid_type'`, `expected: 'never'` and the same guidance string, at the key's own path.
10+
11+
The consequence consumers were missing is now stated with it: a successfully parsed permission entry can carry neither key on any input that came from JSON, so a post-parse guard against either bit is dead code — presence, truthiness and `=== true` alike can never be true on validated data. A `false`-versus-other distinction is observable only to pre-parse tooling reading raw sources, where the retired default is inert legacy residue and any other value is a hard ADR-0049 violation.
12+
13+
One measured exception is documented and pinned, because it is the only post-parse observation that survives: an in-memory TypeScript input carrying an explicit `undefined` for either key parses and keeps the key as an own property whose value is `undefined`, so a presence check can be true there. JSON cannot spell it, and a serialize round-trip drops it again.
14+
15+
<!-- adr-0087: not-required (no-migration-prescription) nothing authorable changes shape: no spec key, no Zod schema and no exported symbol is added, removed, renamed or narrowed, and no accepted value moves in either direction, so `os migrate meta` has no edit to make and no ledger id to carry. The retirement this prose describes was registered by its own change; this one only describes it accurately. -->

‎content/docs/permissions/permission-metadata.mdx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,24 @@ objects: {
101101
> now a loud publish-time error carrying this prescription. The keys return
102102
> with the M2 lifecycle initiative (feature + RBAC in one batch, #1883);
103103
> until then a dispatched `restore` / `purge` is denied unconditionally.
104+
>
105+
> **Exactly one value is still accepted, and it is not an author's to write:**
106+
> the literal `false` that the published 17.x toolchain materialized into every
107+
> permission entry of every artifact it built. That emitted default parses as
108+
> inert residue and is silently **stripped** (#12840, so upgrading a runtime
109+
> does not kill artifacts nobody re-published) — a source that still writes
110+
> `allowRestore: false` therefore gets **no error and no key**. Every other
111+
> value is refused: `"false"`, `0`, `1`, `"true"` and `null` all fail exactly
112+
> like `true`, with the same message. **This is not a truthy/falsy split** —
113+
> only the boolean literal `false` is tolerated.
114+
>
115+
> **Consumers of validated data never see either key**, so a post-parse guard
116+
> is dead code: `permissions.allowRestore` is always `undefined`, which makes
117+
> `'allowRestore' in permissions`, `if (permissions.allowRestore)` and
118+
> `permissions.allowRestore === true` all unable to fire. Only **pre-parse**
119+
> tooling reading raw source (a linter or migration tool over
120+
> `objectstack.json`) can tell legacy `false` residue from an ADR-0049
121+
> violation — and it should say something different about each.
104122
105123
## Field Permissions
106124

‎content/docs/protocol/objectql/security.mdx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ Beyond the four CRUD flags, the schema also exposes lifecycle and super-user gra
109109
> a migration prescription. A dispatched `restore` / `purge` is denied
110110
> unconditionally (fail-closed destructive-operation backstop). The flags
111111
> return with the M2 lifecycle initiative (feature + RBAC in one batch, #1883).
112+
>
113+
> One exception, by ruling: the boolean literal `false` — the default the
114+
> published 17.x toolchain emitted into every built artifact — parses as inert
115+
> residue and is silently stripped (#12840), so it raises no error and reaches
116+
> no parsed output. Every other value, `"false"` and `0` included, is refused
117+
> exactly like `true`; this is not a truthy/falsy split. Since neither key can
118+
> reach validated data, guards belong in pre-parse tooling over raw sources,
119+
> never in code reading a parsed permission set.
112120

113121
> Permission sets are **additive-only**: a user's effective capability is the union of every set they hold — directly, via positions, or via the built-in `everyone` baseline (ADR-0090 D5). A `true` anywhere wins; there are no subtraction sets — to withhold, don't grant.
114122

‎packages/spec/src/data/filter-text-operator-declared-type.ts‎

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@
4848
* value (multi-option, `multiple: true`) is the evaluators' own question
4949
* beneath the door, not this table's.
5050
* - **Deferred** — no verdict, the filter proceeds unchanged: a `formula`
51-
* whose `returnType` is absent (unreadable at the seam), and a DOTTED key
51+
* whose `returnType` this table cannot read (absent, or a spelling the
52+
* schema does not declare — but see the formula note below: at the engine
53+
* seam NO formula reaches this door at all, whatever its `returnType`), and
54+
* a DOTTED key
5255
* (`address.city`), which is `filter-dotted-head`'s subject — its
5356
* structured-JSON heads are deliberately unjudged there (live on two of
5457
* three backends, #8371), and this door reading the head's declared type
@@ -61,6 +64,30 @@
6164
* passes and the other three are refused through the same sets — no second
6265
* vocabulary ({@link FORMULA_RETURN_TYPE_AS_FIELD_TYPE}).
6366
*
67+
* ⚠️ THE FORMULA ROWS ARE A JUDGEMENT NO CONSUMER CURRENTLY REACHES. The
68+
* sentence above states what {@link textOperatorDoorVerdict} answers, and it
69+
* is the ruling's answer; it does NOT describe what an author observes today.
70+
* At this door's only consumer — the engine's field-aware seam — a filter over
71+
* a `formula` field never arrives: `assertFilterIsMaterializable` (#8296 /
72+
* #4419) refuses EVERY one of them one door earlier, with `INVALID_FIELD` 400,
73+
* for the broader reason that no driver materialises a column for a formula.
74+
* Measured on the fixture below, `$contains` over each of the five formula
75+
* fields — `returnType` `number` / `text` / `boolean` / `date` / absent —
76+
* answers `INVALID_FIELD` 400 alike, so the `returnType` is never the deciding
77+
* fact and none of the three verdicts above is observable. The non-formula
78+
* rows of this table ARE observed at that seam, with this door's own
79+
* `INVALID_FILTER` 400; the formula rows are the exception, not the rule.
80+
*
81+
* The rows are kept, not retired, and nothing here moves: the verdict function
82+
* is still consulted through its `formula` branch by the engine door, so the
83+
* day formula fields become filterable the answer is already correct, and the
84+
* divergence is pinned by name in the engine package
85+
* (`engine-text-operator-declared-type-door.test.ts`) so it goes red on that
86+
* day. Making this door overtake #8296 for formula would answer ONE condition
87+
* ("a formula field cannot be filtered") with TWO wire codes chosen by
88+
* `returnType`, and would reopen #8296's recorded code assignment — a
89+
* maintainer decision, deliberately not taken here.
90+
*
6491
* `multiple: true` does not change a verdict: the class is the ruling's axis.
6592
*
6693
* ## Beneath the door: #14079's row stays (the two are one contract)
@@ -108,6 +135,14 @@
108135
* Every case passes the SYNTAX door (`parseFilterAST` accepts each filter —
109136
* pinned in this module's test), so a refusal can only be this door's.
110137
*
138+
* ⚠️ EXCEPT the `formula` cases, which neither branch above describes: at the
139+
* engine seam every one of them is refused by the EARLIER #8296 door with
140+
* `INVALID_FIELD` 400 (see the formula note above), so the refusal is not this
141+
* door's and no driver read runs either. A suite driving this table at that
142+
* seam must therefore partition the formula cases out and assert the
143+
* divergence deliberately, rather than fold them into the two branches above —
144+
* which is what `engine-text-operator-declared-type-door.test.ts` does.
145+
*
111146
* ## Deliberately NOT a driver case-set
112147
*
113148
* `scripts/check-driver-conformance.mjs` enrols every `*_CASES` export of a
@@ -219,9 +254,13 @@ export const FORMULA_RETURN_TYPE_AS_FIELD_TYPE: ReadonlyMap<string, string> = ne
219254
* - `door-refusal` — refused before any driver runs (`INVALID_FILTER` / 400).
220255
* - `passes` — a string-valued declared type; the filter proceeds unchanged.
221256
* - `deferred` — the door records NO verdict and the filter proceeds
222-
* unchanged: the declared type is not readable at the seam (a `formula`
223-
* without `returnType`), or the key is not this door's subject (a dotted
224-
* path — `filter-dotted-head`'s).
257+
* unchanged: the declared type is not readable here (a `formula` without
258+
* `returnType`), or the key is not this door's subject (a dotted path —
259+
* `filter-dotted-head`'s).
260+
*
261+
* These are the answers of THIS function. For `formula` they are not what an
262+
* author observes at the engine seam, where the earlier #8296 door refuses
263+
* every formula filter first — see the module header's formula note.
225264
*/
226265
export type TextOperatorDoorVerdict = 'door-refusal' | 'passes' | 'deferred';
227266

@@ -350,7 +389,7 @@ export const TEXT_OPERATOR_DOOR_TYPE_CLASSES: readonly TextOperatorDoorTypeClass
350389
name: 'formula',
351390
types: new Set(['formula']),
352391
verdict: 'by-return-type',
353-
note: 'Judged as the FieldType its declared `returnType` names (`text` passes; `number` / `boolean` / `date` are refused through the same sets); `returnType` absent ⇒ deferred, the declared type is not readable at the seam.',
392+
note: 'Judged as the FieldType its declared `returnType` names (`text` passes; `number` / `boolean` / `date` are refused through the same sets); `returnType` absent ⇒ deferred. ⚠️ Unreachable at the engine seam: the earlier #8296 door refuses EVERY formula filter with INVALID_FIELD 400 whatever the `returnType`, so this row states the contract\'s answer, not an observable one — see the module header.',
354393
},
355394
];
356395

@@ -509,7 +548,7 @@ function caseFor(
509548
verdict,
510549
note: dotted
511550
? 'A dotted path into a structured-JSON field is filter-dotted-head\'s subject (deliberately unjudged there, #8371); this door must not re-close that carve-out by reading the head\'s declared type.'
512-
: 'The declared return type is not readable at the seam — the ruling judges formula only when it is.',
551+
: 'The declared return type is not readable here — the ruling judges formula only when it is. ⚠️ Unreachable at the engine seam: #8296 refuses every formula filter one door earlier (INVALID_FIELD 400) — see the module header.',
513552
};
514553
}
515554
}

‎packages/spec/src/data/index.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ export * from './filter-comparand-type-conformance';
6565
// type can never store a string (the six existing numeric / boolean /
6666
// temporal / structured-JSON classes, by reference) is refused at the engine's
6767
// field-aware seam with INVALID_FILTER 400; string-valued classes pass;
68-
// `formula` is judged by its declared returnType or deferred. Named for the
68+
// `formula` is judged by its declared returnType or deferred — a contract
69+
// answer no consumer currently reaches, because the earlier #8296 door refuses
70+
// every formula filter with INVALID_FIELD 400 first (see the module). Named for the
6971
// door it declares, like `filter-comparand-type`, not as a driver case-set:
7072
// drivers sit beneath this door and keep answering FILTER_TEXT_CASES' row.
7173
export * from './filter-text-operator-declared-type';

‎packages/spec/src/security/permission.test.ts‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,43 @@ describe('[#12840] the RETIRED DEFAULT parses as inert residue and strips (class
236236
// The helper contract: the residue value is the literal captured at
237237
// retirement time (`false`), compared by identity. Falsy near-misses are
238238
// NOT the emitted default and land on the tombstone like any authored value.
239-
for (const wrong of [true, 0, '', null, 'false'] as const) {
240-
const r = ObjectPermissionSchema.safeParse({ allowRestore: wrong } as never);
241-
expect(r.success, `value ${JSON.stringify(wrong)} must NOT be tolerated`).toBe(false);
242-
expect(r.error!.issues.map((i) => i.message).join('\n')).toContain('ObjectQL operation it claimed');
239+
// [#17425] The full matrix, re-measured on this tree: NOT a truthy/falsy
240+
// split — the string `'true'` and the number `1` are refused exactly like
241+
// the string `'false'` and the number `0`, all with the same issue shape.
242+
for (const wrong of [true, 0, 1, '', 'true', 'false', null] as const) {
243+
for (const key of ['allowRestore', 'allowPurge'] as const) {
244+
const r = ObjectPermissionSchema.safeParse({ [key]: wrong } as never);
245+
expect(r.success, `value ${JSON.stringify(wrong)} must NOT be tolerated`).toBe(false);
246+
const issue = r.error!.issues.find((i) => i.path.join('.') === key)!;
247+
expect(issue, `${key}=${JSON.stringify(wrong)} must be refused AT ITS OWN PATH`).toBeDefined();
248+
expect(issue.code).toBe('invalid_type');
249+
expect((issue as unknown as { expected?: string }).expected).toBe('never');
250+
expect(issue.message).toContain('ObjectQL operation it claimed');
251+
}
243252
}
244253
});
245254

255+
it('[#17425] the only post-parse observation left: an EXPLICIT `undefined` survives as an own key', () => {
256+
// The consumer-facing claim this pins (prose on `ObjectPermissionSchema`):
257+
// on data that came from JSON no post-parse guard can ever fire — `false`
258+
// strips and every other JSON value throws, so the key is always
259+
// `undefined`. But an in-memory TS/JS input carrying an explicit
260+
// `undefined` — what spreading an object that once had the key produces —
261+
// parses AND keeps the OWN key, so a presence check can still be true.
262+
// ⛔ If a zod upgrade moves this, re-measure and rewrite the prose; do not
263+
// relax the pin, because the prose is what consumers act on.
264+
const parsed = ObjectPermissionSchema.parse({ allowRead: true, allowRestore: undefined } as never) as Record<string, unknown>;
265+
expect(Object.prototype.hasOwnProperty.call(parsed, 'allowRestore')).toBe(true);
266+
expect('allowRestore' in parsed).toBe(true);
267+
expect(parsed.allowRestore).toBeUndefined();
268+
// …and the two guards consumers were told to write stay dead regardless.
269+
expect(parsed.allowRestore === true).toBe(false);
270+
expect(Boolean(parsed.allowRestore)).toBe(false);
271+
// JSON cannot spell it: a serialize round-trip drops the key again, which
272+
// is why raw JSON sources can never reach this branch.
273+
expect('allowRestore' in (JSON.parse(JSON.stringify(parsed)) as object)).toBe(false);
274+
});
275+
246276
it('the residue strips inside a full permission-set / stack-shaped parse (the artifact path)', () => {
247277
// The measured refusal was located at
248278
// `permissions[5].objects.crm_campaign_member.allowRestore` — a composed

‎packages/spec/src/security/permission.zod.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,15 @@ const ObjectPermissionBaseSchema = lazySchema(() => strictObject(
217217
* and is STRIPPED by the residue stage on {@link ObjectPermissionSchema}
218218
* (`OBJECT_PERMISSION_RETIRED_KEY_RESIDUE`); the tombstones below never see
219219
* it. Every other value still lands here, prescription intact.
220+
*
221+
* [#17425] The accept set that leaves behind, stated exactly — the residue
222+
* stage tolerates ONE value, the boolean literal `false`, compared by
223+
* identity against the captured literal. **This is not a truthy/falsy
224+
* split**: `"false"`, `0`, `''` and `null` are refused exactly like `true`
225+
* is, with the same `code: 'invalid_type'` / `expected: 'never'` issue at
226+
* the key's own path and the same guidance string. Anything that is not the
227+
* captured literal is an authored claim, and authored claims are what the
228+
* tombstone exists to refuse. The matrix is pinned in `permission.test.ts`.
220229
*/
221230
allowRestore: retiredKey(
222231
'`objects.<object>.allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — ' +
@@ -293,6 +302,32 @@ const ObjectPermissionBaseSchema = lazySchema(() => strictObject(
293302
* tombstone with its prescription. Maintainer ruling 2026-08-28 (recorded on
294303
* objectstack-ai/cloud#1685): a retired key that had a schema default is
295304
* refused only when it carries a non-default value.
305+
*
306+
* ## [#17425] What a consumer of PARSED output can observe: effectively nothing
307+
*
308+
* Every spelling reachable from JSON is gone by the time you hold parsed data:
309+
* `false` is stripped, and every other JSON-expressible value (`true`,
310+
* `"true"`, `"false"`, `0`, `1`, `null`) throws before a parsed object exists.
311+
* So on validated data `permissions.allowRestore` is always `undefined`, which
312+
* makes `if (permissions.allowRestore)` and `permissions.allowRestore === true`
313+
* dead code — a post-parse guard against either bit can never fire, and
314+
* `=== true` is no fix for a truthiness check because a raw `true` never
315+
* survives the parse either.
316+
*
317+
* ONE observation survives, and it is not reachable from JSON: an in-memory
318+
* TS/JS input carrying an EXPLICIT `undefined` (`{ allowRestore: undefined }`
319+
* — the shape a spread of an object that once carried the key produces) parses,
320+
* and the key survives as an OWN property whose value is `undefined`. So
321+
* `'allowRestore' in parsed` can be `true` while the value is still undefined;
322+
* `JSON.parse(JSON.stringify(parsed))` drops it again. Measured and pinned in
323+
* `permission.test.ts`.
324+
*
325+
* ⇒ A `false`-versus-other distinction has a live consumer only in PRE-PARSE /
326+
* raw-source tooling — a linter or migration tool reading `objectstack.json`
327+
* (or a `.ts` source) before validation, where `false` is inert legacy residue
328+
* and any other value is a hard ADR-0049 violation. Those two facts deserve
329+
* different messages; a presence or truthiness check on raw input conflates
330+
* them, and the same check on parsed output measures nothing at all.
296331
*/
297332
export const ObjectPermissionSchema = lazySchema(() =>
298333
acceptRetiredDefaultResidue(ObjectPermissionBaseSchema, OBJECT_PERMISSION_RETIRED_KEY_RESIDUE),

0 commit comments

Comments
 (0)