Skip to content

Commit fac48f7

Browse files
committed
Merge origin/main into the /storage bridge retirement (#4087)
#4058 step 2 (#4086) landed in between and touched the same ground: it gave every dispatcher domain the shared `isServiceServeable` predicate, `/storage` among them. Resolution: - `domains/storage.ts` — deletion stands. #4086 gated the bridge; it did not fix the call, which is still `upload(file, { request })` against `upload(key, data, options?)`. Gating a handler that cannot serve either answer only changes which failure a caller gets. - `http-dispatcher.ts` discovery — take main's `isServiceServeable` and drop the local `filesServeable` this branch had introduced. Same predicate, better home. The comment records why `file-storage` stays gated even though its surface is no longer a dispatcher domain: `handlerReady` is not standing in for anything there, it IS the fact the advert needs. - The two `/storage` cases #4086 added to the handlerReady block go with the domain. The `degraded store keeps serving` one asserted a 200 off `upload` mocked to resolve `{ key }` — a return value `Promise<void>` does not have, the same imagined contract every other retired storage test encoded. - `metadata-protocol` — `file-storage` stays in `DISPATCHER_GATED_SERVICES`; its comment no longer cites a `/storage` bridge that exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015T5CeitwV1jXLvsL1A84tw
2 parents e7be87f + c20b875 commit fac48f7

71 files changed

Lines changed: 2324 additions & 221 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/types': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/runtime': patch
5+
'@objectstack/cli': patch
6+
---
7+
8+
**Correct the stale premise left behind by #4012: the degraded-boot stderr copy
9+
survives the operator's LOG LEVEL, not `os serve`'s boot-quiet window.**
10+
11+
`emitDegradedBootBanner` writes the `OS_ALLOW_DRIVER_CONNECT_FAILURE` banner to
12+
stderr in addition to `logger.warn`, and every comment and test name explaining
13+
why cited the same reason: `os serve` swallowed all of stdout while the kernel
14+
booted, and `Logger` routes `warn` to stdout. #4012 fixed that — the boot window
15+
now buffers and replays `warn`-and-above — which retires the *stated*
16+
justification for a duplicate that is nonetheless still load-bearing:
17+
18+
`Logger.write()` returns before touching a stream when the record is below
19+
`config.level`, so at `--log-level error`, `fatal` or `silent` the banner's
20+
`logger.warn` reaches **no** stream at all. A production host at `error` is
21+
exactly the deployment this escape hatch exists for, and exactly where a
22+
logger-only banner would vanish. Removing the stderr copy on the strength of
23+
#4012 would therefore have been a regression — so this documents the reason that
24+
is still true, in the places someone would read before deleting it:
25+
`degraded-boot.ts`, the engine's emit site, and all three parity tests
26+
(objectql, runtime, service-datasource), which are renamed off "which `os serve`
27+
boot-quiet cannot swallow" to "which the operator log level cannot filter away".
28+
29+
The objectql parity test now proves the claim instead of asserting around it: it
30+
drives a **real** `ObjectLogger` at `level: 'error'` and requires the banner on
31+
stderr *and* nothing on stdout. Set the level to `warn` and it fails — so the
32+
test is pinned to the level filter rather than passing for any reason.
33+
34+
Also corrected in the same sweep, all comment-only, all previously overstating
35+
what #4012 had not yet fixed:
36+
37+
- the automation wiring summary (`format.ts`, `serve.ts`, its test) claimed the
38+
boot window swallowed the engine's binding warnings. Its real justification is
39+
stronger and unchanged: a flow that silently fails to arm emits **no** log line
40+
at any level, so binding state has to be read off the live engine — absence of
41+
a warning was never evidence of a bound flow.
42+
- the seed summary (`seed-summary.ts`, `format.ts`, its test) and `AppPlugin`'s
43+
seed-outcome note attributed the silence to the boot window; the operative
44+
gate is that `SeedLoader`'s result logs are `info`, under the default `warn`.
45+
46+
No behavior changes.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
---
3+
4+
`domain-handler-registry.test.ts` now uses `envelopeViolations` instead of its own
5+
copy of the rule. Deliberately empty frontmatter: test-only, this releases nothing.
6+
7+
That test hand-rolled "no key beside the envelope's own may hold the payload" for
8+
#4038. #4090 promoted the rule into the spec so it would stop having two
9+
definitions; leaving the local copy would have recreated the exact failure this
10+
line has been closing — one rule, two places, only one updated next time.
11+
12+
The two were also not equivalent. The local set allowed any body whose top-level
13+
keys were `success` / `data` / `meta`, so it passed a success body with no `data`
14+
at all and one carrying an `error` beside `success: true`. The shared predicate
15+
rejects both, which makes this a strict tightening rather than a refactor:
16+
injecting `{ success: true }` into the producer now fails the suite, and used to
17+
pass it.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
Gate every dispatcher service domain on `handlerReady` instead of on slot occupancy (#4058 step 2).
7+
8+
#4000 made the `/analytics` domain execute ADR-0076 D12's third conclusion ("consumers treat only `handlerReady: true` as a real capability"); every other domain still gated on "is a service registered", so a self-declared stub occupying `automation` / `notification` / `ai` / `file-storage` / `i18n` was called like a real implementation and its fabricated answer went out as a 200. Step 1 (#4082) made the two kinds of dev implementation distinguishable; this is the gate that reads the distinction.
9+
10+
- The `/analytics`, `/automation`, `/notifications`, `/ai`, `/storage` and `/i18n` domains, the route-mount gate, discovery's `routes`/`features`, and the metadata-protocol builder's route advertisement now share one predicate (`isServiceServeable`): a slot whose occupant self-declares `handlerReady: false` is answered exactly as an empty slot is — the domain's existing 404, or the explicit 501 `/storage` and `/i18n` use. One predicate, so what is advertised and what is served cannot disagree.
11+
- `handlerReady`, not `status`, is the test. An implementation that declares `degraded` defaults to `handlerReady: true` and keeps serving — which is why the in-memory `file-storage` and `i18n` implementations are unaffected.
12+
- `discovery.services.*` stays presence-gated: a registered stub still reports `{ enabled: true, status: 'stub', handlerReady: false }` (with no `route`), which says strictly more than collapsing it to `unavailable` would.
13+
- `/ai` improves for the stub case: an occupied-but-unserveable slot used to fall through to a 503 "AI service routes not yet initialized" and lose the `GET /ai/agents` empty-list answer the console polls for on every navigation. Both are restored.
14+
15+
No change for a host whose services are real implementations. If you register your own stub under one of those six slots and relied on the dispatcher calling it, either drop the `handlerReady: false` self-declaration (declare `degraded` if it genuinely serves) or install the real service. Not gated, deliberately: `/data`, `/meta`, `/auth` and the security path — their dev stubs back the dev stack's own core loop, and gating them would 404 the dev stack itself.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
**`envelopeViolations` — the conformance check `BaseResponseSchema` cannot express.**
6+
7+
Every conformance suite from the #3843 line leads with
8+
`BaseResponseSchema.safeParse(body)`, under a comment claiming it is "the contract
9+
itself, imported — not a restatement of it". That overclaimed, and the gap is
10+
demonstrable:
11+
12+
```ts
13+
BaseResponseSchema.safeParse({ success: true }) // passes
14+
BaseResponseSchema.safeParse({ success: true, data: link, link }) // passes
15+
```
16+
17+
The schema declares no `data` — each response type adds its own via
18+
`.extend({ data })` — and a plain `z.object` strips unknown keys rather than
19+
rejecting them. So it catches the one drift it was added for (a missing or
20+
non-boolean `success`, the flag `unwrapResponse` keys on) and nothing else. The
21+
second body above is exactly the duplicate-payload drift `/share-links` shipped
22+
until #4038 / #4049 removed it, and `safeParse` passed it the whole time.
23+
24+
`envelopeViolations(body)` returns every departure from the declared envelope as
25+
readable reasons, empty when conformant:
26+
27+
- `success` must be a **boolean**
28+
- a success body must carry `data` (`undefined` only — `null`, `[]`, `{}`, `0`
29+
and `''` are payloads, not absences)
30+
- a failure body must carry `error` with a string `code` and `message` — the
31+
nested form, not the pre-#3675 bare string
32+
- no top-level key outside `success` / `data` / `error` / `meta`, which is the
33+
general form of the duplicate-payload drift
34+
35+
It deliberately does **not** check the shape of `data`: that is each route's own
36+
payload schema, and conflating the two is what let `SettingsNamespacePayload`
37+
describe a whole body before #3843 and only `data` after it.
38+
39+
The ten conformance suites now assert it beside `safeParse`, and their comments
40+
say what each of the two actually proves. Reintroducing the `/share-links`
41+
duplicate key is caught by the new assertion and still passes the old one — which
42+
is the demonstration that the pairing is the point.
43+
44+
Placed in `contract.zod.ts` beside the schema it completes, alongside the other
45+
plain predicates `spec/api` already exports (`standardErrorCodeForHttpStatus`,
46+
`readServiceSelfInfo`). No new package.

.changeset/retire-dispatcher-storage-bridge.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,11 @@ Two follow-on corrections keep `declared === enforced`:
5353
every other path under it — service-storage's protocol above all — got the
5454
bridge's own 404 rather than falling through. Storage is ordinary catch-all
5555
traffic now.
56-
- Discovery advertises `routes.storage` only when the `file-storage` slot's
57-
occupant actually mounts HTTP handlers (`handlerReady`, the predicate #4000
58-
gave analytics), and plugin-dev's in-memory implementation now self-declares
59-
`handlerReady: false` — with the bridge gone, nothing routes HTTP to it. It
60-
keeps working for in-process callers; it is simply no longer advertised as a
61-
reachable HTTP capability.
56+
- Discovery keeps gating `routes.storage` on `isServiceServeable` — the shared
57+
`handlerReady` predicate #4058 step 2 introduced — and plugin-dev's in-memory
58+
implementation now self-declares `handlerReady: false`. #4058 deliberately
59+
left that one serving because the `/storage` bridge was still there to serve
60+
it; with the bridge retired nothing routes HTTP to that slot, so `false` is
61+
the honest value — the position `realtime` has held since ADR-0076 D12. The
62+
implementation keeps working for in-process callers; it is simply no longer
63+
advertised as a reachable HTTP capability.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
---
4+
5+
feat(metadata): `saveMeta` persists the operator spellings the spec normalized (objectui#2945)
6+
7+
`ViewFilterRuleSchema.operator` is `z.preprocess(normalizeFilterOperator, …)`, so
8+
a stored `notEquals` / `gt` / `isNull` is folded to its canonical form during
9+
save-time validation — and then the result was thrown away. `saveMetaItem`
10+
persists the authored body verbatim, deliberately: `parsed.data` strips the
11+
Studio-only auxiliary fields (`isPinned`, `isDefault`, `sortOrder`) that ride
12+
along with an overlay document (ADR-0005 §Validation).
13+
14+
The consequence is that **every save mints new legacy-alias rows.** The ~30
15+
entries in `VIEW_FILTER_OPERATOR_ALIASES` are documented as *"a migration bridge
16+
[that] may be dropped in a future major"*, but there is no point at which the
17+
last alias row is behind you, so the bridge can never be dismantled — a
18+
migration that rewrote every existing row would be obsolete the moment the next
19+
console personalization PUT landed. That is prerequisite 2 of the vocabulary
20+
consolidation blocked in objectstack-ai/objectui#2945.
21+
22+
`graftNormalizedOperators` grafts the normalization back on without giving up the
23+
verbatim body. It walks the authored value and `parsed.data` in lockstep **by
24+
structure** and copies across exactly one thing: an `operator` whose parsed value
25+
differs from the authored one.
26+
27+
- **No key list to maintain.** `ViewFilterRule[]` appears at five declared sites
28+
today (view `filter`, `ViewTab.filter`, page `filterBy`, and two
29+
`component.zod.ts` block props) and the structural walk covers all of them,
30+
plus any added later. Enumerating paths would have reproduced in this file the
31+
exact duplication #2945 exists to remove.
32+
- **Nothing else moves.** Only an `operator` string is rewritten, and only where
33+
both sides are strings — so a `$`-token `FilterCondition`, a different operator
34+
vocabulary entirely, cannot be reshaped by accident. No key is added, removed,
35+
reordered or defaulted; the unary `{field, operator}` form does not acquire a
36+
`value` even though the schema's own output would give it one.
37+
- **Nothing is allocated when nothing changed**, so a body already written in
38+
canonical form is returned by identity.
39+
40+
Behaviour change worth stating plainly: a `GET` after a `PUT` now returns the
41+
canonical spelling rather than the one the author sent. That is the spelling the
42+
spec defines, every renderer accepts it (objectstack-ai/objectui#2974,
43+
objectstack-ai/objectui#2989 pinned all three of objectui's translation tables to
44+
the full vocabulary), and it is the point of the change. Existing rows are not
45+
touched — this stops the bleeding, it is not the migration.
46+
47+
Verified: 11 new tests, including one that drives **every** alias the spec still
48+
folds through the real `ViewMetadataSchema` and asserts the persisted body comes
49+
out canonical; full `@objectstack/metadata-protocol` suite 110 tests / 18 files
50+
green.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
---
3+
4+
test(showcase): a specimen for legacy string rowActions (objectui#2960)
5+
6+
Releases nothing. The change is confined to `@objectstack/example-showcase`,
7+
which is `"private": true` and never published — it adds the
8+
`showcase_task.legacy_row_actions` view (a fixture for the objectui#2960 bug
9+
class) and `recordIdParam` on `showcase_recalc_estimate`, so no consumer-facing
10+
package changes.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-analytics": patch
4+
---
5+
6+
feat(spec): a shared temporal conformance matrix, and the `$between` gap it found (ADR-0053 D-A3, #4081)
7+
8+
`@objectstack/spec/data` gains `TEMPORAL_ROWS` and `TEMPORAL_CASES` — the
9+
single set of temporal filter cases every backend is checked against, the twin
10+
of the existing `FILTER_LOGIC_CASES`. Five backends consume it and assert **row
11+
results**: `driver-sql` (and, through the live-dialect CI job, real Postgres and
12+
MySQL), `driver-memory`, `driver-mongodb` (real MongoDB), the analytics preview
13+
evaluator, and `formula`'s RLS write-side `check`.
14+
15+
This is the regression backstop ADR-0053 D-A3 has asked for since 2026-06 and
16+
the last of its decisions to be actioned. Four separate incidents — #3650,
17+
#3773, #3777, #4047 — were each found by a human by accident, and each left a
18+
suite proving only its own issue against its own fixture. Nothing held the
19+
backends to one standard, so the fifth divergence had nowhere to fail.
20+
21+
**`service-analytics` — a real fix the matrix found on its first run.** The
22+
draft-preview evaluator had no `$between` case, so it fell through to its
23+
permissive `default` and matched **every** row: a drafted dashboard carrying a
24+
range filter charted the entire dataset, then changed its numbers at publish —
25+
the exact continuity the preview exists to provide. It now evaluates
26+
`$between`, sharing the upper-bound helper with `$lte` so the whole-day
27+
calendar-day rule (#3777) applies to a range's max as well.
28+
29+
Also recorded (ADR-0053 D-A3.1): `$gt` with a bare-day comparand on a
30+
`datetime` column cannot agree between typed and type-blind backends, and the
31+
gap is irreducible without field types. It is asserted in the shared matrix on
32+
`date` only, with the `datetime` cell left to the typed drivers' own suites,
33+
rather than papered over.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: reject unknown keys on RLS policies, sharing rules, and positions (#4001 step 2)
6+
7+
Second click of the unknown-key strictness ratchet (first: flow + permission,
8+
#4071), extending `.strict()` + the `strictUnknownKeyError` fixable-error
9+
factory to the remaining small security-class authoring surfaces, per
10+
`docs/audits/2026-07-unknown-key-strictness-ledger.md`:
11+
12+
- **`security/rls.zod.ts`**`RowLevelSecurityPolicySchema` is `.strict()`.
13+
A silently dropped key on an RLS policy meant a row-level restriction the
14+
author wrote was never compiled into the filter. The runtime shapes
15+
(`RLSUserContextSchema`, `RLSEvaluationResultSchema`) stay tolerant. The
16+
retired `priority` key keeps its existing tombstone.
17+
- **`security/sharing.zod.ts`** — the sharing-rule surface is `.strict()`
18+
(base + criteria extension + the `sharedWith` recipient shape). A silently
19+
dropped key meant a share the author intended was never materialised.
20+
- **`identity/position.zod.ts`**`PositionSchema` is `.strict()`, and gains
21+
the author-facing `protection` block plus the ADR-0010 runtime protection
22+
envelope (`_lock`, `_packageId`, `_provenance`, …) — closing the sibling
23+
gap the #4071 ledger flagged: `applyProtection` stamps every registered
24+
metadata type, and position was the last one whose schema could not
25+
represent the stamp.
26+
27+
**Migration.** Any key these schemas now reject was previously stripped and
28+
had **no runtime effect** — removing or renaming it never changes behavior.
29+
The error carries the fix; FROM → TO mappings baked in include:
30+
31+
- RLS policy: `roles`/`role``positions` (ADR-0090 D3 rename),
32+
`withCheck``check` (the PostgreSQL spelling), `condition`/`filter`/`where`
33+
`using`. `priority` stays a tombstone (#3896: OR-combined policies have no
34+
precedence to order — delete the key).
35+
- Sharing rule: `criteria``condition` (the persisted row spells the
36+
compiled predicate `criteria_json`; the authored key is the CEL
37+
`condition`), `access`/`level``accessLevel`,
38+
`recipient`/`shareWith`/`sharedTo``sharedWith`, `enabled``active`;
39+
recipient `id`/`target``value`. `ownedBy` carries the removed
40+
owner-type-rule prescription (only `criteria` rules are authorable).
41+
- Position: `title``label`; `permissionSets` / `users` are runtime
42+
bindings (`sys_position_permission_set` / `sys_user_position`), never
43+
authored on the position; `parent` is rejected with the flatness rule
44+
(ADR-0090 D3 — hierarchy is the business-unit tree, not a position tree).

AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,8 +312,9 @@ export default {
312312
working tree.
313313
3. **Add a changeset for feature work.** When the change is a feature or functional improvement, run `pnpm changeset` (or add a `.changeset/*.md` entry) describing it before committing. Pure bug fixes do **not** require a changeset.
314314
**Breaking changesets must carry their migration.** If the change removes or renames anything an author can write (a spec key, an export, a config field), the changeset body must state the FROM → TO mapping and the one-line fix — this text ships to consumers as `CHANGELOG.md` inside the npm package and is what an upgrading agent greps after the tombstone error. Removing an authorable spec key also requires a tombstone so the rejection itself carries the prescription — `retiredKey()` (`packages/spec/src/shared/retired-key.ts`) on a non-strict schema, or an entry in the relevant `UNKNOWN_KEY_GUIDANCE` / `*_RETIRED_KEY_GUIDANCE` map (see `object.zod.ts`, `ai/tool.zod.ts`) when the schema is `.strict()`. The changeset is one of fourteen surfaces a retirement touches — follow the `spec-property-retirement` skill (`.claude/skills/`) rather than reconstructing the kit, and note the two routes imply **opposite** liveness-ledger dispositions.
315-
4. Update `CHANGELOG.md` / `ROADMAP.md` if user-facing or architectural.
316-
5. **Delete temporary artifacts** — screenshots, traces, scratch logs, `.playwright-mcp/`, throwaway `tmp*.ts`, ad-hoc scripts. Repo must look identical to before, minus intended changes.
315+
4. **Added or removed a `packages/spec` export? Run `pnpm --filter @objectstack/spec gen:api-surface` and commit the result.** The `TypeScript Type Check` job diffs spec's built export surface against `api-surface.json`; a new export makes the snapshot stale and turns the job red. It reads the **built `dist` declarations**, so `OS_SKIP_DTS=1` — the flag you reach for to make local builds fast — skips exactly the artifact the gate inspects, and the check passes locally while failing in CI. Same shape for the other generated-artifact gates in that job (`check:docs`, `check:skill-refs`, `check:react-blocks`), which read `src/` and so do reproduce locally.
316+
5. Update `CHANGELOG.md` / `ROADMAP.md` if user-facing or architectural.
317+
6. **Delete temporary artifacts** — screenshots, traces, scratch logs, `.playwright-mcp/`, throwaway `tmp*.ts`, ad-hoc scripts. Repo must look identical to before, minus intended changes.
317318

318319
---
319320

0 commit comments

Comments
 (0)