Skip to content

Commit 39ba6e6

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-17536-client-either-stage-widening
2 parents 8fa0d5a + e3b3cdd commit 39ba6e6

21 files changed

Lines changed: 2027 additions & 88 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
`seed-name-lookup.ts` — the batched seed existence read's OWN failure now reaches the author when no logger was injected, through the one delivery derivation the package already owns (#18570).
6+
7+
The oracle every declared-metadata seeder consults hoists one `$in` read out of its loop and degrades to the per-item read when that read cannot answer — an outage, or a page proven to be a prefix of the answer. It reported that degradation through a doubly-optional `logger?.warn?.(…)`, which evaluates to NOTHING when the caller injected no sink: the read failed, the pass silently switched to the slow path, and no human was told.
8+
9+
Measured differentially rather than read off the code, in this package's own `bootstrap-declared-capabilities` control harness: an unreadable-database pass with **no logger** printed exactly **one** author-visible line — the seeder's own end-of-pass summary — while the batched read that failed *first* said nothing. With this change the same pass prints **two**, and that assertion is now the pin (`toHaveLength(1)` → `toHaveLength(2)`, both lines selected by content).
10+
11+
- **Delivery only.** The wording, the structured meta (`object`, `names`, `rowBudget`, `organization`) and the two named causes — `unreadable` and `truncated` — are axis-specific and stay at the call site, which is the split `seed-refusal-sink.ts` documents. ⛔ No sixth hand-written copy of the rule, and ⛔ no generic refusal sentence.
12+
- **A read that ANSWERED stays silent on every channel**, with or without a sink — the discriminating control that keeps a healthy boot quiet.
13+
- **It also stops a throw.** `logger?.warn?.(…)` guards `null`/`undefined`, never a non-callable `warn`: a host that declared one and shipped something else raised `TypeError: logger?.warn is not a function` *inside* the degradation path, turning a slower read into a failed boot. The site now asks `typeof` — the same question `reportThroughSink` asks — so such a host takes the console arm instead.
14+
- **No exported surface moves.** `seed-name-lookup.ts` is package-private (`src/index.ts` re-exports nothing from it) and `SeedLookupLogger` is unchanged, both members still optional.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
`/discovery` advertises `capabilities.transactionalBatch` from the predicate the atomic-batch refusal already trusts, so the advertisement and the 501 stop disagreeing (#18997).
6+
7+
`getDiscovery()` derived the bit from the ENGINE alone — `typeof this.engine?.transaction === 'function'` — while `runAtomicBatch` refuses `batchData({ atomic: true })` with `501 NOT_IMPLEMENTED` on `engineCanRollBack(engine)`, which asks the DEFAULT DRIVER as well. `engine.transaction` is a function on every real engine, so the advertisement answered `true` for compositions that then 501 — and the 501's own remedy text sends the caller to that very bit ("probe `capabilities.transactionalBatch` on /discovery first"). The prescribed remedy routed the caller to a signal that was wrong in exactly the case the remedy exists for.
8+
9+
**What a consumer sees.** Two compositions, measured separately, stop advertising `true` and now advertise `false`:
10+
11+
- **(a) a default driver with no `beginTransaction` at all** — pre-existing, not introduced by #18063;
12+
- **(b) a default driver that inherits `beginTransaction` and declares `supports.transactionsUnsupported`** — the population #18063 added; the shipped example is `TursoDriver` on its remote transport.
13+
14+
Both already answered `501 NOT_IMPLEMENTED` to an atomic batch, so nothing that was accepted becomes refused. A client that read `true` and proceeded was taking the 501; it now reads `false` and takes its non-atomic fallback ahead of the failure — which is what probing the capability was for. A client that hard-asserts `transactionalBatch === true` at startup against such a composition fails at startup instead of at the first atomic batch.
15+
16+
Unchanged in the other direction, and pinned so that "honest" cannot decay into "always `false`": a composition whose default driver **can** roll back still advertises `true`, and so does a host whose driver registry is not inspectable (test doubles, metadata-only hosts), where the engine-level probe is all there is. Measured over all 16 compositions of the four inputs the two predicates read: 0 go `false` → `true`, 3 go `true` → `false`.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/metadata-protocol": minor
4+
---
5+
6+
The dataset publish door now judges the dataset. A runtime-created `dataset` reached ZERO author-time rules; it now dispatches the existence rules that were already written for it (#19143).
7+
8+
`dataset` is a registered metadata type declaring `allowRuntimeCreate: true`, so Studio's designer, REST `/meta` item CRUD and an MCP/AI author may all mint one — and at that door nothing judged it. Measured on `origin/main`: no rule declared `dataset` in `runtimeTypes` (zero, against a lit control returning every other declared type), and `TYPE_TO_STACK_KEY` in `runtime-gate.ts` carried no `dataset` row. The two absences were **consistent rather than contradictory** — the gate filters by `runtimeTypes` before it consults the table — so nothing was mis-wired and CI was green, correctly. What they summed to is that a dataset write built no per-write snapshot and dispatched no rule at all, while the rules that judge a dataset state their own failure mode as a surface that *"renders successfully with empty or wrong numbers"*. An author working only through Studio or MCP has no `os lint` step to fall back on, so for them that door is the only one there is.
9+
10+
ADR-0049's 「声明即强制」 admits two resolutions and the card chose neither; this takes the first because the measurement says so. The author-time rules for `dataset` **exist**: `validateDatasetReferences` (#14105, `packages/lint/src/validate-dataset-references.ts`), `validateDatasetMeasureAggregates` (#16354) and `validateObjectReferences`' `datasets[].object` rung. The declaration is honoured rather than retired.
11+
12+
- **`TYPE_TO_STACK_KEY` gains `dataset: 'datasets'`**, and the rules that READ that collection are declared in the same commit — never a mapping ahead of its rules, which is the inert state the table's own `seed: 'data'` note records paying for. Every crossed rule has a door control that fires it through the real gate.
13+
- **`validateDatasetMeasureAggregates` crosses to `CLI_AND_RUNTIME` with `runtimeTypes: ['dataset']`.** Its previous `surfaceReason` named this exact gap as what held it off the door.
14+
- **The reference-integrity suite entry gains `dataset`**, and its per-member axis admits exactly two members — `validateDatasetReferences` and `validateObjectReferences`. Both resolve only against `stack.objects` and `stack.datasets`, the two collections a per-write snapshot carries, so neither opens a missing-collection false-positive channel. They cross together on #7220's reading: an author refused for a dangling dimension field and waved through for a dangling base object cannot predict the door.
15+
- **No new rule and no new finding class.** The rule ids (`dataset-field-unknown`, `dataset-field-not-included`, `dataset-filter-field-unknown`, `dataset-include-unknown`, `measure-aggregate-field-type-refused`, `object-reference-unknown`) and their severities are unchanged — they now reach the door where the author actually is.
16+
- **Findings from a dataset write are name-keyed on the wire** (#10064): `datasets.<name>.dimensions[0].field`, never the gate's private snapshot index. `datasets` entered the derived name-keyed set by derivation, with no second edit to remember.
17+
- **Measured before crossing**, at the door's own snapshot shape and differential, over every dataset shipped in this monorepo — **11 datasets** (`platform-objects` 5 over `sys_*`, showcase 4, crm 1, todo 1) judged against 52 platform objects plus each app's own (showcase 22, crm 6, todo 1): **0 findings, 0 advisories, `rulesRun` 2 on every one** — so the zero is a fact about the corpus and not about a door that ran nothing. The same harness's synthetic probe IS refused, with both ids and both name-keyed paths.
18+
19+
## Migration
20+
21+
**A dataset publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id and the offending path, name-keyed on the wire — for example `datasets.invoice_metrics.dimensions[0].field` or `datasets.invoice_metrics.measures[1].aggregate` — plus the string that was written.
22+
23+
To clear a refusal, do one of:
24+
25+
- point the `dimensions[].field` / `measures[].field` path at a column the base object actually declares (after a Studio label edit the derived API name is the one to use); or
26+
- add the relationship the path traverses to the dataset's `include[]`, for `dataset-field-not-included`; or
27+
- correct the filter KEY, for `dataset-filter-field-unknown`; or
28+
- for `measure-aggregate-field-type-refused`, either aggregate a field of an accepted type or choose an aggregate the field's type accepts (`count` / `count_distinct` accept every type) — the compile leg already refuses that same pair with `400 DATASET_INVALID` once a query is built, so this is the same fix made earlier; or
29+
- for `object-reference-unknown`, point `object` at an object this stack defines, or at a platform object by its full name.
30+
31+
`os validate` / `os build` / `os lint` already reported every one of these findings at the same severity, so a code-authored stack can be repaired before it ever reaches a publish. A dataset over an object this stack does not define, one that declares no readable field map, and a registry-injected system column are all skipped exactly as they were on the CLI — the door adds no verdict the commands did not already make. A stored dataset already in violation is never charged to an unrelated publish, and republishing a dataset under its own name with the defect removed is clean (#4463 D4).
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
---
4+
5+
The activity-timeline summary resolves a reference field's target through `referenceTargetOf` instead of the materialized `reference` carrier, so a `trackHistory`'d `{ type: 'user' }` field authored without one is planned, read and rendered as a name instead of silently showing the raw id (#19264).
6+
7+
`audit-writers.ts` admitted `user` as a reference type and then required an EXPLICIT `reference` on it. The spec declares exactly the opposite for that type: `IMPLICIT_REFERENCE_TARGETS` (`@objectstack/spec/data`) says a `user` field's target is "a CONSTANT OF THE TYPE, so `reference` on a `user` field materializes that constant; it does not supply it. Metadata authored without it (hand-written JSON, an AI author, a Studio form) is **fully specified, not under-specified**." So the one spelling the contract calls complete was the one the reader refused — and it refused it **silently**: the field was simply absent from the read plan, and the timeline rendered `usr_1` where every other reference field showed a name.
8+
9+
- **Four sites, not two.** The target is the key of the `id → title` map, so it has two ends: the two read planners (`planTrackedLookupReads`, `planMilestoneTokenReads`) build the plan under it and the two renderers (`renderTrackedChangeSummary`, `renderMilestoneSummary`) look the resolved titles back up under it. All four now ask one helper, so repairing the plan alone cannot pay for a read whose result the renderer then fails to find.
10+
- **Nothing else widens.** The admitted types are unchanged (`lookup`, `master_detail`, `user`), so a `lookup` / `master_detail` whose author-chosen target is absent still names nothing, is still left out, and still issues no read — `tree` is deliberately not added.
11+
- **A padded carrier can no longer split the key.** The planners used to `trim()` and the renderers did not, so `reference: ' crm_account '` produced two keys and no title; one helper trims once for both ends.
12+
- **The unreadable-carrier behaviour is unchanged.** `referenceTargetOf` reads the carrier through `referenceCarrierOf`, which throws for an object- or array-valued `reference`; that throw is caught at the helper because this code runs inside `writeAudit`'s summary composition, which is not inside the `try` that guards the audit row write — an escaping `TypeError` would turn a display-enrichment miss into a failure on the audited write's own path. Such a carrier is left out exactly as it was before.
13+
- **No authoring change.** Metadata that already spells `reference: 'sys_user'` resolves to the same target it always did; nobody has to restate the constant.

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -617,23 +617,31 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
617617
input: 'parsed',
618618
commands: ALL,
619619
source: 'packages/lint/src/validate-dataset-measure-aggregates.ts',
620-
// NOT RUNTIME_NEEDS_FULL_SNAPSHOT: the two collections this rule reads —
621-
// `objects` and `datasets` — are both carried (#7529). What holds it off
622-
// the door is the type axis: the metadata type that CARRIES the
623-
// declaration is `dataset` (`allowRuntimeCreate: true`), and
624-
// `TYPE_TO_STACK_KEY` in `runtime-gate.ts` has no `dataset` row, so a
625-
// dataset write builds no per-write snapshot and no rule can be dispatched
626-
// for it. Declaring another type here would only re-judge a STORED
627-
// dataset, which the #4463 D4 differential cancels as someone else's
628-
// pre-existing condition — wired, and enforcing nothing. Mapping the
629-
// `dataset` type at the gate is its own card (every rule reading
630-
// `stack.datasets` gains the door at once, including the existence rules).
631-
surfaces: CLI_ONLY,
632-
surfaceReason:
633-
'The declaring metadata type is `dataset`, which `runtime-gate.ts`\'s TYPE_TO_STACK_KEY does '
634-
+ 'not map — a dataset write builds no per-write snapshot, so nothing can dispatch this rule '
635-
+ 'there; declaring any other type would only re-judge a stored dataset, which the publish '
636-
+ 'gate\'s differential cancels as somebody else\'s pre-existing condition.',
620+
// [#19143] The door this rule was written for, finally reachable. The
621+
// previous `surfaceReason` here was never RUNTIME_NEEDS_FULL_SNAPSHOT —
622+
// the two collections this rule reads, `objects` and `datasets`, are BOTH
623+
// carried (#7529). What held it off was the TYPE axis: the metadata type
624+
// that carries the declaration is `dataset` (`allowRuntimeCreate: true`),
625+
// and `TYPE_TO_STACK_KEY` had no `dataset` row, so a dataset write built
626+
// no per-write snapshot and nothing could be dispatched for it. That row
627+
// lands in the same commit as this declaration, which is the order the
628+
// `seed: 'data'` note demands: never a mapping without a reading rule,
629+
// never a declaration without a mapping.
630+
//
631+
// ⛔ `runtimeTypes: ['dataset']` and nothing else, still. Declaring another
632+
// type would only re-judge a STORED dataset, which the #4463 D4
633+
// differential cancels as somebody else's pre-existing condition — wired,
634+
// and enforcing nothing. The written dataset is the only one this door may
635+
// answer for.
636+
//
637+
// ⚠️ A REFUSAL widening on a door that previously refused nothing: a
638+
// runtime dataset write whose measure pairs an aggregate with an
639+
// incompatible field type is now 422 rather than silently stored. MEASURED
640+
// over the shipped dataset corpus before crossing, at the door's own
641+
// snapshot shape: 11 datasets (platform-objects 5, showcase 4, crm 1,
642+
// todo 1) — 0 findings, with a lit synthetic probe refused.
643+
surfaces: CLI_AND_RUNTIME,
644+
runtimeTypes: ['dataset'],
637645
run: (stack) => validateDatasetMeasureAggregates(stack),
638646
},
639647
// ADR-0049 / #3367 — a dashboard header action naming a dead target ships a
@@ -805,8 +813,39 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
805813
// The gate's differential keeps it honest in the one direction that
806814
// matters: a STORED sibling already in violation is never charged to this
807815
// write (#4463 D4).
816+
// [#19143] `dataset` joins, and the same granularity mechanism keeps it a
817+
// NARROW crossing: this entry says a `dataset` write dispatches the suite;
818+
// the suite's per-member `runtimeTypes` says exactly TWO members judge that
819+
// snapshot — `validateDatasetReferences` (the dimension / measure / include
820+
// / filter-key existence rules, #14105) and `validateObjectReferences`
821+
// (whose `datasets[].object` rung owns the base-object name, deliberately
822+
// split off from the rule above so it gets the curated
823+
// `PLATFORM_PROVIDED_OBJECT_NAMES` ladder). Both resolve only against
824+
// `stack.objects` and `stack.datasets`, the two collections a per-write
825+
// snapshot carries, so neither has a missing-collection false-positive
826+
// channel. Every other member keeps its existing declaration and does not
827+
// run on a dataset write.
828+
//
829+
// The two cross TOGETHER on #7220's reading: they judge the SAME document
830+
// and their split is an implementation detail of the severity ladder, so an
831+
// author refused for a dangling dimension field and waved through for a
832+
// dangling base object could not predict the door.
833+
//
834+
// The measured state that forced it: `runtimeAuthoringRulesFor('dataset')`
835+
// dispatched NOTHING — zero rules, on a type every tenant may create at
836+
// runtime and whose own existence rules state their failure mode as a chart
837+
// that renders successfully with empty or wrong numbers.
838+
//
839+
// ⚠️ A REFUSAL widening, like #15254's: a dataset republished with a
840+
// pre-existing dangling field path is now refused (422) rather than stored
841+
// silently. The gate's differential keeps it honest in the one direction
842+
// that matters — a STORED sibling already in violation is never charged to
843+
// this write (#4463 D4). MEASURED over the shipped dataset corpus before
844+
// crossing, at the door's own snapshot shape: 11 datasets
845+
// (platform-objects 5, showcase 4, crm 1, todo 1) — 0 findings, with a lit
846+
// synthetic probe refused.
808847
surfaces: CLI_AND_RUNTIME,
809-
runtimeTypes: ['flow', 'view', 'object'],
848+
runtimeTypes: ['flow', 'view', 'object', 'dataset'],
810849
run: (stack, ctx) => validateReferenceIntegrity(stack, ctx),
811850
},
812851
// ADR-0078 / #5068 — the SDUI component-props gate. `PageComponent.properties`

0 commit comments

Comments
 (0)