Skip to content

Commit 462e0bb

Browse files
committed
Merge origin/main into claude/issue-19268-liveness-walk-seam
Taken so the gate union is run against a tree that exists: dispatch-gates flagged this head as 3 commits behind with scripts/check-published-files.mjs changed in that range, i.e. one of the families it derives would have run from a stale local copy. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
2 parents f2587e1 + 32b5831 commit 462e0bb

8 files changed

Lines changed: 935 additions & 13 deletions
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**BREAKING for callers** — `parseFilterAST` now refuses a blank `$between` endpoint, exactly as the authoring schema already does. An empty-string or absent (`undefined`) bound, at either side, is refused with `INVALID_FILTER` / 400, and the refusal names the blank side — MIN or MAX, plus the index (#19071).
6+
7+
Clause-②: no
8+
9+
## What changed, and why it is the implementation catching up rather than a new rule
10+
11+
`RANGE_ENDPOINT_DESCRIPTION` — the published endpoint contract shared by both of `$between`'s bounds — has stated since 2026-09-17 that "BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side". That rule shipped at the authoring schema only. The runtime door disagreed with it: `parseFilterAST({ at: { $between: ['', ''] } })` returned the filter unchanged, same object reference, measured on `origin/main` before this change and re-measured after.
12+
13+
One published sentence therefore had two truth values, decided by which door a caller came through — and the door that passed it is the one that matters most here. A caller that lowers a filter with `parseFilterAST` and hands it straight to a driver (an embedder; this repo's own driver conformance suites) never meets the schema. At every backend a blank bound stops bounding on that side while the range still reads as a complete two-element range, so the query runs with one meaningless boundary and returns rows outside the window its filter names, with no signal at any layer.
14+
15+
```
16+
FROM parseFilterAST({ at: { $between: ['2026-01-01', ''] } })
17+
-> { at: { $between: ['2026-01-01', ''] } } // unchanged, same reference,
18+
// straight on to the driver
19+
20+
TO parseFilterAST({ at: { $between: ['2026-01-01', ''] } })
21+
-> throws INVALID_FILTER / 400:
22+
'Operator "$between" on field "at" requires two non-blank bounds.
23+
Received an empty string at where.at.$between[1] (the MAX bound). …'
24+
```
25+
26+
## Migration — FROM → TO
27+
28+
| You wrote | Write instead |
29+
| --- | --- |
30+
| `{ $between: ['2026-01-01', ''] }` | `{ $between: ['2026-01-01', '2026-12-31'] }` — the bound you meant, written out |
31+
| `{ $between: ['', 100] }` | `{ $between: [0, 100] }` — the lower bound you meant |
32+
| a range that was only ever bounded on ONE side | `{ "$gte": min }` or `{ "$lte": max }` — a one-sided bound is not a range |
33+
34+
**The one-line fix: write the bound that is missing, or — if only one side was ever meant — drop `$between` and write that side as a scalar comparison.** The same prescription the authoring door already gives, now given at the door an embedder actually reaches.
35+
36+
## What does NOT change
37+
38+
- **`null` bounds** keep their own message, refused since 2026-08-31. It prescribes the null PREDICATE, because an author who wrote `null` was reaching for absence and an author who left a bound empty was reaching for a bound — two blank spellings, two intents, two remedies. `null` is also checked first, so a pair that is blank on one side and null on the other keeps the message it has always had.
39+
- **Whitespace-only endpoints** are still accepted, at BOTH doors. The 2026-09-17 ruling is the empty string; the authoring door pins `{ $between: [' ', 'M'] }` as parsing green on purpose, and trimming here would re-open the very split this change closes, in the opposite direction.
40+
- **Falsiness.** `{ $between: [0, 0] }` and `{ $between: ['0', '9'] }` lower exactly as before. The rule is blankness, not falsiness.
41+
- **`$in` / `$nin` members.** A falsy or empty-string MEMBER is a value, not an absence (2026-08-31, `filter-comparand-shape.test.ts`). Only a range ENDPOINT is judged here, and only the `$between` row of that pin moves.
42+
- **Arity**, which was already refused with its own message, and every legal range: numbers, Dates, ISO days, UTC instants, clock times and non-temporal text all lower byte-identically, same object reference.
43+
- **The published export surface.** No export is added, removed or renamed; the refusal rides the existing `$between` arm of the shared comparand-shape door, so the engine's delegating wrapper inherits it unchanged.
44+
45+
<!-- adr-0087: not-required (already-registered filter-between-blank-endpoint-refused) the transition from a blank $between endpoint to two present, non-empty bounds was registered by #18012 for this same surface set; this change adds no new transition, it brings the runtime lowering face under the one already on the ledger, and the entry's own prescription and acceptance criteria apply verbatim -->
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime): `POST /api/v1/packages` parses the manifest's `id` leg instead of reading it positionally (#19417)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for callers of the install door** — a manifest whose `id` is not
10+
reverse-domain notation is now refused `400` / `VALIDATION_ERROR`. It used to
11+
install and answer `201`.
12+
13+
The accept set only shrinks back to what the published declaration has always
14+
said. `MANIFEST_ID_PATTERN` is declared once in
15+
`packages/spec/src/kernel/manifest.zod.ts` and referenced by both faces of one
16+
identity — `ManifestSchema.id`, what an author writes, and
17+
`PackageSchema.manifestId`, what the registry stores and publishes by. The door
18+
read `manifest.id` POSITIONALLY (`typeof manifest?.id === 'string' ?
19+
manifest.id.trim() : ''`) and parsed nothing, so `id: 'pkg-a'` installed and
20+
answered `201` while `defineStack()`, `os build`, `os validate` and the publish
21+
face all refused the same id. The author was handed a package that could never
22+
be rebuilt or published. That is «declared ≠ enforced» on a published API
23+
contract — and nothing in `packages/spec` moves for it: the declaration was
24+
already right.
25+
26+
The gate asks the declaration **by reference** — `ManifestSchema.shape.id` —
27+
rather than keeping a copy of the grammar, exactly as the `version` leg beside
28+
it does, so a future move of the reverse-domain rule reaches this door with no
29+
further edit. The sentence the caller reads is the declaration's own
30+
(`manifestIdRefusal`), **surfaced rather than reworded**: it names the key,
31+
echoes the value, lists the two examples, and carries a suggestion arm that
32+
verifies its candidate against the pattern before offering it. Posting
33+
`id: 'pkg-a'` now answers, in the response envelope's `error.message`:
34+
35+
```text
36+
Invalid package id 'pkg-a' on `manifest.id`. Expected reverse-domain notation
37+
('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments;
38+
hyphens allowed inside a segment, underscores are not. Did you mean
39+
'com.example.pkg-a'?
40+
```
41+
42+
**What is not affected.** Boot-time and in-process installs reach
43+
`SchemaRegistry.installPackage` / `ObjectQL.registerApp` directly and never pass
44+
through this branch, so nothing about how a package is loaded from disk or
45+
registered by a plugin changes. A conforming manifest installs exactly as
46+
before, on both body forms (wrapped and bare) and on both install limbs (the
47+
protocol primitive and the bare-registry fallback).
48+
49+
**The `Package id is required` sentence does NOT move.** `''` fails
50+
`MANIFEST_ID_PATTERN` too, so where this gate sits decides whether a published
51+
message changes or only the accept set does. It is ordered AFTER the existing
52+
`!pkgId` check: an absent, empty, whitespace-only or non-string `id` still
53+
answers `400 Package id is required`, never the schema's sentence — which on
54+
that input is the one case where the suggestion arm has nothing to offer.
55+
Measured both directions. It is ordered BEFORE the `version` gate for the
56+
mirror-image reason: that refusal's sentence names the id it prescribes for, and
57+
prescribing a `version` repair for an id that can never be legal sends the
58+
author round twice.
59+
60+
**Scope — the `id` leg alone.** The declaration's residual docblock records the
61+
classes this door answers `201` to while `PackageInstallBodySchema` refuses
62+
them. This change closes one: `id`. A missing `type`, unknown keys on either
63+
body form, a string-typed `enableOnInstall` / `overwrite`, and install options
64+
spelled on the bare form are each left exactly as they were — measured after the
65+
change, all seven spellings of those four classes still answer `201`, against a
66+
`pkg-a` control that answers `400` and a conforming control that answers `201`.
67+
Each is its own reading and its own card. Closing them is the one call this
68+
handler still pointedly does not make, `PackageInstallBodySchema.safeParse(body)`.
69+
70+
**If you are refused.** Give the manifest an id in reverse-domain notation —
71+
lowercase dot-separated segments, hyphens allowed inside a segment, underscores
72+
not. The refusal names the key, echoes what you wrote and, where a mechanical
73+
repair exists, offers one it has already checked against the rule, so the
74+
prescription arrives with the `400` rather than in a changelog.
75+
76+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or reshaped: no spec key, no export, no stored row. `objectstack migrate meta` has nothing to reach, because there is no old spelling that maps to a new one — a caller supplies an id the declaration already required, and an id's repair changes the package's identity, so no mechanical mapping could be prescribed even in principle. The refusal itself carries the remedy. -->

‎packages/runtime/src/domain-handler-registry.test.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -588,13 +588,41 @@ describe('HttpDispatcher extracted domains (PR-5: packages)', () => {
588588
// ⚠️ The repair is owed whatever order the new gate sits in: the
589589
// `forced` limb asserts `201`, which an under-specified manifest must
590590
// never reach — so no placement of that gate leaves this fixture valid.
591-
const manifest = { id: 'pkg-a', name: 'A', version: '1.0.0' };
591+
//
592+
// [#19417] Same repair, one key over — and the LAST one this fixture
593+
// owed. `ManifestSchema.id` carries `MANIFEST_ID_PATTERN` since #18319,
594+
// `pkg-a` is not reverse-domain notation, and the install door parses
595+
// that leg now: the `forced` limb's `201` was unreachable for this id.
596+
// ⛔ The `pkg-a` reading is REVERSED, never deleted — the case below
597+
// keeps it, asserting the refusal this fixture used to assert the
598+
// acceptance of. `com.example.pkg-a` is the same name made publishable,
599+
// which is the repair `manifestIdRefusal` itself prescribes for it.
600+
const manifest = { id: 'com.example.pkg-a', name: 'A', version: '1.0.0' };
592601
const dup = await dispatcher.dispatch('POST', '/packages', manifest, {}, {} as any);
593602
expect(dup.response?.status).toBe(409);
594603
const forced = await dispatcher.dispatch('POST', '/packages', manifest, { overwrite: 'true' }, {} as any);
595604
expect(forced.response?.status).toBe(201);
596605
});
597606

607+
it('[#19417] POST /packages refuses `pkg-a` — the id the pattern refuses (the REVERSED pin)', async () => {
608+
// ⭐ This case is the other half of the repair above, and it is why that
609+
// repair is not a deletion. `pkg-a` was the one standing pin on the gap
610+
// this card closes: the door answered it `201` while `defineStack()`,
611+
// `os build`, `os validate` and the publish face all refused it, so an
612+
// author could install a package that could never be rebuilt or
613+
// published. The reading is kept, pointing the other way.
614+
//
615+
// ⛔ Deliberately thin here — status only. The full pins (the
616+
// declaration's own sentence, both install writers silent, and the
617+
// gate's placement against `Package id is required`, the `version` leg
618+
// and the `409`) live in `domains/packages-install-manifest-id.test.ts`
619+
// beside the door. This file's subject is ROUTING.
620+
const objectql = qlWithRegistry();
621+
const result = await withPkgCaller(makeDispatcher({ objectql }))
622+
.dispatch('POST', '/packages', { id: 'pkg-a', name: 'A', version: '1.0.0' }, {}, {} as any);
623+
expect(result.response?.status).toBe(400);
624+
});
625+
598626
it('POST /packages without an id is rejected with 400', async () => {
599627
const objectql = qlWithRegistry();
600628
const result = await withPkgCaller(makeDispatcher({ objectql })).dispatch('POST', '/packages', { name: 'no-id' }, {}, {} as any);

0 commit comments

Comments
 (0)