Skip to content

Commit fcd296b

Browse files
committed
Merge origin/main (945ffbe) into claude/issue-11717-lead-key-allowlist
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
2 parents 61aea8b + 945ffbe commit fcd296b

12 files changed

Lines changed: 845 additions & 34 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
Carry the undeclared-authoring-key warnings in the `os build --json` payload,
6+
so its `warnings` list matches `os validate --json` on the same tree
7+
8+
`os build --json` reported a strictly smaller `warnings` list than
9+
`os validate --json` did for the same stack, and the missing members were
10+
exactly the "your key was dropped at load" ones (#3786 / ADR-0087). A CI job
11+
gating on `os build --json` therefore could not see the class of warning that
12+
silently discards authored metadata — while the identical job gating on
13+
`os validate --json` did.
14+
15+
Measured over one temp project at `origin/main` `4ceae8ab0`, three faces of one
16+
authored stack whose field carries an undeclared key nested in `visibleWhen`:
17+
18+
```
19+
os build ⚠ Undeclared authoring keys (1) — dropped at load (#3786)
20+
os validate --json warnings: [ {rule record}, "…zzzUnknownKey…" ]
21+
os build --json warnings: [ {rule record} ] ← the dropped list
22+
```
23+
24+
`compile.ts` computed the findings and then formatted them **inside** the
25+
`if (… && !flags.json)` print block, which put them structurally out of reach
26+
of the payload: computed, then discarded, for the one audience `--json` exists
27+
to serve. `os validate --json` had this exact defect on its own face and fixed
28+
it by mapping the findings through `formatUnknownAuthoringKey` at the
29+
computation site; `os build` now does the same, so one list feeds both faces
30+
and they cannot report different sets.
31+
32+
**No new key.** The findings land in the `warnings` key the payload already
33+
declared — its own comment has always said "the whole registry's advisory set,
34+
in the shape `os validate --json` reports" — carried as formatted strings
35+
beside the authoring-rule records, which is byte-for-byte the heterogeneous
36+
shape `os validate --json` already ships. Consumers reading `warnings` off
37+
either command now read one shape for one class of warning. The payload's
38+
top-level key set is unchanged and pinned as unchanged.
39+
40+
This also makes an existing promise true. The truncation notice added in
41+
17.2.0 ends with "re-run with `--json` for the full list"; that pointer was
42+
honest about the authoring-rule advisories and would have been false about the
43+
undeclared-key list, which is why that change left the second list without a
44+
notice.
45+
46+
Text output is unchanged.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
---
5+
6+
`FieldSchema` now rejects an authored `deleteBehavior: 'set_null'` on a `master_detail` field at parse time (#9689). The engine has always resolved every value except `restrict` on that type to `cascade`, so the declaration asked for the child rows to be kept and got them deleted — silently, at the moment the parent went away. The rejection names the outcome and both legal re-declarations (`restrict` refuses the parent delete while children exist — no data loss; `cascade`, or omitting the key, accepts the cascade deliberately; a `lookup` is the type to use when children must survive the parent).
7+
8+
Mechanism (the #7918 Option A shape, plus the 2026-08-24 idempotent-materialization ruling): the property-level `.default('set_null')` moved off `deleteBehavior` into a post-check `.overwrite()`, so the schema can tell an authored `set_null` from a defaulted one — and the `.overwrite()` never materializes a default the schema itself would refuse as authored. A bare `master_detail` now parses to output that OMITS `deleteBehavior` (previously the baked `set_null` was indistinguishable from an authored one by design, so parse output rejected itself on the mainline `ObjectSchema.create()` → `defineStack` re-parse — every app build with a bare `master_detail` failed). Built app artifacts stop carrying a value the schema itself refuses; the engine treats absent exactly as it treated the baked value (both cascade — measured, behavior unchanged). Every other field type keeps byte-identical output — non-reference types still carry the default at its shape position, and `set_null` on `lookup` stays legal. The inferred `Field` output type now declares `deleteBehavior` as optional (the same accepted cost as the currency `precision` relocation); at runtime a parsed field carries it on every type except `master_detail`, where absence is the honest spelling.
9+
10+
There is deliberately no automatic conversion (`field-master-detail-set-null-refused` in the migration registry): only the author knows whether they meant `restrict` (keep-my-children, as a refusal) or `cascade`. Stored rows carrying the refused combination keep loading and serving — registry validation is a diagnostic, not a gate — and are refused on their next authoring-path save.
11+
12+
`@objectstack/objectql`: the engine behavior is unchanged (an authored `set_null` on `master_detail` still cascades — the #9625 pin holds), but the coercion site now logs loudly (`error`, falling back to `warn`) when the combination reaches it via a raw registration or a pre-tightening stored row — the two populations parse-time rejection cannot catch.

content/docs/data-modeling/field-types.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ Parent-child relationship (cascading delete by default).
337337
|:---|:---|:---|:---|
338338
| `reference` | `string` | **required** | Target (master) object name |
339339
| `referenceFilters` | `string[]` || **Removed** (#2377, ADR-0049) — no longer a recognized field property. `FieldSchema` is a strict object, so an unknown key is **rejected with guidance**, not silently stripped (ADR-0078): the error echoes the offending key and prescribes the replacement. Use structured `lookupFilters` + `dependsOn` instead; see [Relationships](/docs/data-modeling/relationships) |
340-
| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'cascade'` | Behavior when parent is deleted. `restrict` is the only value that deviates: master-detail cascades on everything else, so an explicit `set_null` here is **not** honored — the child is deleted with the parent |
340+
| `deleteBehavior` | `'restrict' \| 'cascade'` | `'cascade'` | Behavior when parent is deleted. `restrict` refuses the parent delete while children exist; `cascade` (and omitting the key) deletes the children with the parent. An explicit `set_null` is a **parse-time rejection** on this type (#9689) — a detail row cannot outlive its master; use a `lookup` if children must survive the parent |
341341
| `inlineEdit` | `boolean \| 'grid' \| 'form'` || Edit child records inline on the parent create/edit form (`true` = auto-pick, `'grid'`, or `'form'`) |
342342
| `inlineColumns` | `array` || Optional explicit inline grid columns |
343343
| `inlineAmountField` | `string` || Numeric child field used for the inline running total |

content/docs/protocol/objectql/types.mdx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -698,9 +698,13 @@ const opportunities = await engine.find('opportunity', {
698698
> been emptied.
699699
>
700700
> On `master_detail` the same reading applies from the other side: `restrict`
701-
> is the only value that deviates from `cascade`, so an explicit
702-
> `deleteBehavior: set_null` on a master-detail reference is *not* honored —
703-
> the child is cascaded away.
701+
> is the only value that deviates from `cascade`. An explicit
702+
> `deleteBehavior: set_null` on a master-detail reference is a **parse-time
703+
> rejection** (#9689) — a detail row cannot outlive its master, so the spec
704+
> refuses the declaration instead of silently cascading the children it asked
705+
> to keep. Metadata that bypasses the parse (a raw registration, or a row
706+
> stored before the tightening) still resolves to `cascade`, now with a loud
707+
> engine log at the coercion site.
704708
>
705709
> The refusal carries **two** messages, for two audiences. `error` is written for
706710
> the person who clicked delete: it is rendered in the caller's locale from the

packages/cli/src/commands/compile.ts

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -285,14 +285,26 @@ export default class Compile extends Command {
285285
// through it; this covers the ones that skip it (a plain object
286286
// default-export, `strict: false`) and would otherwise emit an
287287
// artifact with the key quietly gone. Advisory, never fatal.
288-
const unknownKeyFindings = [
288+
//
289+
// [#11643] FORMATTED HERE, once, and consumed by BOTH faces — the
290+
// text block just below and the `--json` payload at the end of this
291+
// command. The findings used to be formatted inside that print
292+
// block, i.e. under `!flags.json`, which made them structurally
293+
// unreachable for the payload: computed, then discarded, for the one
294+
// audience `--json` exists to serve. `os validate --json` had this
295+
// exact defect and fixed it this exact way —
296+
// `.map(formatUnknownAuthoringKey)` at the computation site, beside
297+
// its own `normalized` — so hoisting the formatting rather than
298+
// restating it at the payload is what keeps the two faces from
299+
// reporting different sets. One list cannot drift from itself.
300+
const unknownKeyWarnings = [
289301
...lintUnknownStackKeys(normalized as Record<string, unknown>, ObjectStackDefinitionSchema),
290302
...lintUnknownAuthoringKeys(normalized as Record<string, unknown>, ObjectStackDefinitionSchema),
291-
];
292-
if (unknownKeyFindings.length > 0 && !flags.json) {
293-
printWarning(`Undeclared authoring keys (${unknownKeyFindings.length}) — dropped at load (#3786)`);
294-
for (const f of unknownKeyFindings.slice(0, 50)) {
295-
console.log(` • ${formatUnknownAuthoringKey(f)}`);
303+
].map(formatUnknownAuthoringKey);
304+
if (unknownKeyWarnings.length > 0 && !flags.json) {
305+
printWarning(`Undeclared authoring keys (${unknownKeyWarnings.length}) — dropped at load (#3786)`);
306+
for (const w of unknownKeyWarnings.slice(0, 50)) {
307+
console.log(` • ${w}`);
296308
}
297309
}
298310

@@ -464,15 +476,39 @@ export default class Compile extends Command {
464476
// The whole registry's advisory set, in the shape `os validate --json`
465477
// reports. This key used to carry the widget rule's warnings alone —
466478
// one gate out of the twenty-odd that raise them.
467-
warnings: ruleAdvisories,
479+
//
480+
// [#11643] …and then, still, only the RULE advisories: the #3786
481+
// undeclared-authoring-key findings were computed above and dropped,
482+
// so a CI consumer reading `warnings` off this command saw a strictly
483+
// smaller set than the same consumer reading it off
484+
// `os validate --json` on the same tree — missing exactly the "your
485+
// key was dropped at load" members. Two costs, both real: the machine
486+
// faces of two commands disagreed about one class of warning, and
487+
// #11529's truncation notice points the reader at `--json` "for the
488+
// full list", which was true of one advisory list and false of the
489+
// other.
490+
//
491+
// MIXED BY DESIGN, because that is what parity means here. This list
492+
// now carries the rule advisories as RECORDS and the undeclared-key
493+
// findings as formatted STRINGS — byte-for-byte the shape
494+
// `os validate --json` has shipped since it fixed this on its own
495+
// face (`warnings: [...ruleAdvisories, ...docWarnings,
496+
// ...unknownKeyWarnings, …]`, likewise a heterogeneous list). The
497+
// homogeneity this key used to have was not a contract; it was the
498+
// symptom of the omission.
499+
warnings: [...ruleAdvisories, ...unknownKeyWarnings],
468500
// [#10678] Body-extraction failures that made a callable fall back to
469-
// the legacy .mjs bundle. A SEPARATE key on purpose: `warnings` above
470-
// is author-time RULE advisories (`{where,message,rule,path,hint}`)
471-
// and is the shape `os validate --json` shares — folding a different
472-
// record shape (`{origin,reason}`) into it would break that contract
473-
// for every consumer that reads one shape from either command. Empty
474-
// array when every callable lowered cleanly, so a CI consumer can read
475-
// the key unconditionally.
501+
// the legacy .mjs bundle. A SEPARATE key on purpose, and the reason is
502+
// parity too — the opposite way round from `unknownKeyWarnings` just
503+
// above. `{origin,reason}` extraction records have NO counterpart in
504+
// `os validate --json`: that command lowers no handlers, so there is
505+
// no cross-command list for these to join, and folding a shape only
506+
// ONE command can ever emit into the shared key would teach consumers
507+
// a shape the other command never ships. The undeclared-key findings
508+
// are the mirror case — validate already carries them, in `warnings`,
509+
// so that is where build has to carry them too. Empty array when every
510+
// callable lowered cleanly, so a CI consumer can read the key
511+
// unconditionally.
476512
bodyExtractionWarnings: lowering.bodyExtractionWarnings,
477513
// Same key `os validate --json` uses, so a CI consumer reads one shape
478514
// from either command rather than learning two.

0 commit comments

Comments
 (0)