Skip to content

Commit acb99ba

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20193-dispatcher-meta-read-gate
2 parents b58d036 + ab82001 commit acb99ba

24 files changed

Lines changed: 1951 additions & 76 deletions
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime): `POST /api/v1/packages` parses the whole body through `PackageInstallBodySchema` instead of reading it key by key (#19328)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for callers of the install door.** The door now enforces the
10+
WHOLE install-body declaration. A body that `PackageInstallBodySchema` refuses
11+
is refused with `400` / `VALIDATION_ERROR` and installs nothing, whatever the
12+
reason. Every shape below used to answer `201`, and two of the install options
13+
used to be honoured. The list names the shapes a caller is most likely to have
14+
sent, and ends with one bullet for everything else the declaration states. For
15+
each one, change what you send FROM the refused shape TO the declared one:
16+
17+
- **A manifest with no `type`, in either body form.** FROM
18+
`{ "manifest": { "id": …, "name": …, "version": … } }` (or the same manifest
19+
sent bare) TO the same manifest with a declared `type`: one of `app`,
20+
`plugin`, `ui`, `driver`, `server`, `theme`, `agent`, `objectql`, `module`,
21+
`gateway` or `adapter`, e.g. `"type": "app"`. It used to install anyway.
22+
- **A manifest with no `name`, in either body form.** `name` is the manifest's
23+
other required key besides `id`, `version` and `type`. FROM
24+
`{ "manifest": { "id": …, "version": …, "type": … } }` (or the same manifest
25+
sent bare) TO the same manifest with `"name": "…"`, the human-readable
26+
package name. It used to install anyway, because the door parsed only the
27+
`id` and `version` legs.
28+
- **An unknown key inside the manifest, or on a bare body.** Example: a
29+
transposed `namesapce`. FROM a manifest carrying the undeclared key TO the
30+
manifest with that key removed, or spelled as the declared key it was meant
31+
to be (`namespace`). The refusal names the key. The key used to be STORED
32+
with the package.
33+
- **A string-typed `enableOnInstall` or `overwrite`.** FROM
34+
`"enableOnInstall": "false"` / `"overwrite": "true"` TO JSON booleans,
35+
`"enableOnInstall": false` / `"overwrite": true`. `'false'` used to install a
36+
fresh package ENABLED, which is the opposite of what the caller asked for.
37+
`'true'` for `overwrite` was read as absent, which answered `409` on an
38+
installed id.
39+
- **Install options spelled on the BARE form (`enableOnInstall`, `overwrite`,
40+
`settings`).** FROM `{ "id": …, …, "overwrite": true }` TO the wrapped form,
41+
`{ "manifest": { "id": …, … }, "enableOnInstall": …, "overwrite": …,
42+
"settings": … }`. `overwrite` may also go on the query string instead
43+
(`?overwrite=true`), which a bare body may keep using. Before this change the
44+
door handled these key by key: `enableOnInstall` was ignored, but
45+
**`overwrite: true` and `settings` were HONOURED** (an installed id was
46+
overwritten, and the settings reached the install). All three were also
47+
stored as manifest keys. They are refused now. This is the part of the
48+
change that removes behaviour a caller could have been relying on.
49+
- **Any other constraint `ManifestSchema` declares.** Each one is now enforced
50+
at this door. Before, only the `id` and `version` legs were, and the manifest
51+
was stored as sent. The constraints include:
52+
- the `namespace` grammar: 2–20 characters, starting with a lowercase
53+
letter, with only lowercase letters, digits and underscores;
54+
- the closed value sets of `scope` (`cloud`, `system`, `project`),
55+
`runtime` and `packaging`;
56+
- the declared type of a key (for example a non-string `description`, or a
57+
non-string version in `dependencies`);
58+
- the retired manifest keys `configuration`, `capabilities`, `extensions` and
59+
`loading`, which the declaration keeps only as named refusals;
60+
- unknown keys inside the nested blocks the declaration closes. These are
61+
`contributes` (and its `kinds[]`), `data[]` (each entry is a `SeedSchema`
62+
seed), `navigationContributions[]` (and their items), `engine`, `engines`,
63+
and the structured form of `permissions`.
64+
65+
FROM the off-declaration value TO the value the declaration states. The
66+
refusal names the path.
67+
68+
The accept set only shrinks back to what the published declaration has always
69+
said. `PackageInstallBodySchema` in `@objectstack/spec` is a union of two
70+
branches: the wrapped request, or a bare manifest as the whole body.
71+
`ManifestSchema` declares the manifest's required keys (`id`, `name`,
72+
`version`, `type`) and the grammar or value set of the rest. It closes the
73+
manifest, and the nested blocks listed above, against unknown keys.
74+
The wrapped request types `enableOnInstall` and `overwrite` as booleans. Its
75+
docblock says a bare manifest carries no install options: «a caller that needs
76+
an option sends the wrapped form». Until now, the door parsed only two legs of
77+
the manifest (`id` and `version`) and read every other key positionally off
78+
the raw body. That is «declared ≠ enforced» on a published API contract. Now
79+
the door parses the body once through the declared union and reads
80+
`overwrite`, `settings` and `enableOnInstall` off the parsed request. Nothing
81+
in `@objectstack/spec` moves, and neither branch of the union is relaxed.
82+
83+
**What is not affected.**
84+
85+
- A well-formed body installs exactly as before, in both forms and on both
86+
install limbs (the protocol primitive and the bare-registry fallback).
87+
- The first-party SDK (`client.packages.install(m, { enableOnInstall,
88+
overwrite, settings })`) already sends the wrapped form with JSON booleans.
89+
Studio's create-package dialog sends `{ manifest }` with a declared `type`.
90+
- The manifest is stored as sent, with no parse-time defaults added.
91+
- The refusals for a missing or malformed `id` and `version` keep their own
92+
sentences and still come first.
93+
- Every request-shape refusal is still answered ahead of the duplicate-id
94+
`409`.
95+
- Boot-time and in-process installs reach `SchemaRegistry.installPackage` /
96+
`ObjectQL.registerApp` directly and never pass through this door.
97+
98+
**Still accepted, because the declaration accepts it.** An unknown key at the
99+
top level of the wrapped form (`{ manifest, bogus }`) is dropped, not refused.
100+
`PackageInstallRequestSchema` is declared in strip mode, and the door now does
101+
exactly what the declaration says.
102+
103+
**If you are refused.** The refusal names the path of each failed constraint,
104+
and the form it read the body as. A misplaced install option also gets the wrapped form (and, for
105+
`overwrite`, `?overwrite=true`) spelled out. So the prescription arrives with
106+
the `400` rather than in a changelog.
107+
108+
<!-- 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 — every refused shape is one the published declaration already refused, and a caller repairs it by sending the declared shape (a declared `type`, a `name`, the declared key or value, a JSON boolean, the wrapped form). The refusal itself carries the remedy. -->
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): the analytics ObjectQL face asks the engine's own filter admission about a row-level read scope before composing it, and refuses a scope the engine refuses with the policy withheld (#19995)
6+
7+
Clause-②: no
8+
9+
The ObjectQL execute face composes each object's read scope into the `where` it hands `engine.aggregate`. A scope the engine refuses through a door that reads the object's declared fields (a text operator over a field that never holds a string, a temporal comparand the field cannot read, a filter on a formula field, a dotted path through a lookup) came back as the engine's `INVALID_FILTER` or `INVALID_FIELD` / 400. Both analytics HTTP doors relay a 400's message, and that message named the policy's field, and for some classes its operator or comparand. A read-scope refusal is a server fault whose detail belongs in the server log only (the #5367 ruling), so these scopes now answer `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld, like the other refusals this package's read-scope compiler and guards raise. The `driver-sql` refusals that read the `'policy'` provenance mark are unchanged: they stay a withheld `INVALID_FILTER` / 400.
10+
11+
**How.** The analytics face asks the engine's judge-only admission, `IObjectQLEngine.judgeFilter`, about the scope on its own before composing it. It asks at every engine-bound merge: the direct aggregate, both merges on the cross-object path, and the record-label lookup behind a lookup dimension. The engine runs the same admission it runs when it executes and stops before any driver, so a scope the engine serves is still served. The caller's own `where` is not judged here and keeps the engine's answer, including its 400 and message.
12+
13+
**Also fixed.** The record-label lookup `AnalyticsServicePlugin` supplies for a lookup dimension composed the referenced object's scope with only the vacancy guard. When a dataset sorted by that dimension's labels, a scope the engine refused there came back as its 400, with the policy in the message. When a dataset only displays the labels, a failed lookup is caught and the raw ids render, as before. The lookup now runs the same checks as the other merges and answers the same withheld 500.
14+
15+
**Wiring, and what a host without it keeps.**
16+
17+
- `AnalyticsServicePlugin` wires the judge automatically when it bridges `executeAggregate` to the kernel's `data` engine itself. That is the default composition, so nothing changes in host code.
18+
- A host that constructs `AnalyticsService` directly can pass the new optional `AnalyticsServiceConfig.judgeFilter`. It must be the judgement of the engine its `executeAggregate` runs on.
19+
- A host with no judge (a custom `executeAggregate`, or a `data` engine without `judgeFilter`) keeps today's behaviour everywhere except the plugin's record-label lookup. The scope shapes this package judges itself are still refused with the policy withheld, and the rest reach the engine unjudged, as before. That lookup's comparand and placeholder checks are new for every host that uses it, with or without a judge. So on such a host a referenced-object scope that fails one of them now answers the withheld 500 at that lookup, where it used to reach the executor. The host logs one `warn` that no judge is wired, with the remedy.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec)!: `scale` is refused on a `currency` inline grid column, and the column's `prefix` no longer promises a default symbol (#20045)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. An `inlineColumns` entry that declares `type: 'currency'` and `scale` — any value, `scale: 0` included, computed or not — no longer parses. The hand-migration prescription is registered under protocol major 18 as `inline-grid-column-currency-scale-refused`.
10+
11+
A currency's decimal places are the currency's, not a setting. `scale` was already retired from the `currency` field type; the inline grid column, the strict mirror of the console grid's column, still offered per-column decimals on a currency column. It now follows the field.
12+
13+
**`@objectstack/spec`** — `InlineGridColumnSchema` refuses `scale` on a column declaring `type: 'currency'`, with a located issue at the column's `scale`. The refusal opens with the currency field refusal's first sentence and carries its remedy: delete the key; the currency's ISO 4217 minor unit decides how the cell displays the amount and the width a computed amount is rounded to. The remedy names no other key to carry the value. No alias and no grace window. `scale` on a `number` column, and on a column that declares no `type`, is untouched, and the key's describe now names the currency refusal. The column's `prefix` describe no longer promises a `¥` default: it replaces the resolved currency's symbol, and when it is omitted the cell shows the symbol of the currency it resolves. `prefix` is still accepted on a currency column.
14+
15+
## FROM → TO
16+
17+
| you wrote | write instead |
18+
|:--|:--|
19+
| `inlineColumns: [{ name: 'amount', type: 'currency', computed: true, expr: 'quantity * unit_price', scale: 2 }]` | `inlineColumns: [{ name: 'amount', type: 'currency', computed: true, expr: 'quantity * unit_price' }]` |
20+
| `inlineColumns: [{ name: 'unit_price', type: 'currency', prefix: '$', scale: 2 }]` | `inlineColumns: [{ name: 'unit_price', type: 'currency', prefix: '$' }]` |
21+
22+
The one-line fix: delete `scale` from every inline grid column that declares `type: 'currency'`. Nothing replaces it, so ⛔ do not re-declare the value under any other key. A `number` column keeps its `scale`.
23+
24+
## Who is affected, measured
25+
26+
On `origin/main` `1c8b320a89`: one authored `inlineColumns` block in the tree (the showcase invoice, seven identity-only columns, none declaring `type` or `scale`). No platform object, skill, documentation example or JSON fixture declares an inline grid column. One test fixture carried `scale: 2` on a currency column and was re-judged. Deployed metadata was not measured.
27+
28+
<!-- adr-0087: registered inline-grid-column-currency-scale-refused -->

0 commit comments

Comments
 (0)