Skip to content

Commit 1e6f29b

Browse files
committed
Merge origin/main into claude/issue-19992-currency-config-precision-retire
dropped-refinements.baseline.json resolved by the file's own rule (hand-maintained, gated by build-schemas.ts): #20223's inlineColumns.element sites and data/InlineGridColumn root kept, this branch's currencyConfig sites and data/CurrencyConfig root stay removed; header totals recounted from the merged body. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
2 parents 3681939 + 0d3ec47 commit 1e6f29b

37 files changed

Lines changed: 3026 additions & 110 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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
fix(cli): `os info` and `os lint` refuse a stack whose `packages` is present but not an array, instead of reading it as no packages (#19925)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for `os info` and `os lint` on a hand-written stack.** A config whose
10+
`packages` is present but is not an array (`{}`, `0`, `'x'`) is now refused with
11+
`INVALID_ARTIFACT_PACKAGES` (ADR-0112, `status: 422`) and exit code 1. These
12+
commands used to read it as a stack with no packages. `os info` exited 0 and
13+
reported every package-owned collection as empty. `os lint` exited 0 with
14+
`passed: true`. Only two spellings reach these commands with such a value: a
15+
config exported as a plain object, and `defineStack(…, { strict: false })`.
16+
The default `defineStack` parse, `os validate` and `os build` already refused
17+
it.
18+
19+
The accept set only shrinks back to what the declaration has always said.
20+
`ObjectStackDefinitionSchema` declares `packages` as an array of package
21+
entries. Ruling A on #15293 settled that a present non-array value is
22+
malformed, not absent. The runtime, `@objectstack/core` and the plugin readers
23+
already refused it. The CLI's stack-collection reader and its three
24+
package-docs readers still answered "no packages". They now judge the value
25+
through one helper, which hands a non-array to `resolveArtifactPackageOrder`.
26+
So the refusal's code, status and sentence are core's own, and the CLI keeps no
27+
second copy of the rule.
28+
29+
**What is not affected.** An absent `packages` reads exactly as before, and so
30+
does a `packages` array. A malformed entry inside an array is still refused as
31+
`INVALID_ARTIFACT_PACKAGE_ENTRY`. `os serve` and `os dev` refused this stack
32+
before the change, because the runtime's manifest service raises the same
33+
refusal at boot, and they still do.
34+
35+
**`packages: null` follows core's resolver.** Ruling `5805260775` on #19926
36+
makes `null` malformed at every reader, and the resolver's `null` refusal is
37+
landing separately (#19926, PR #20228). The CLI readers do not answer `null`
38+
themselves; they hand it to `resolveArtifactPackageOrder` and return what it
39+
answers. Today that is its absent answer, so `null` still reads as no
40+
packages. Once the resolver refuses `null`, these commands refuse it too, with
41+
no change to the CLI.
42+
43+
**If you are refused.** Omit `packages` for a single-package stack, or give it
44+
an array of `{ manifest: … }` entries. The refusal says the same.
45+
46+
<!-- 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 a non-array `packages` was never a legal spelling of anything, so no old form maps to a new one. The refusal itself carries the remedy. -->

‎.changeset/19951-rls-lint-per-request-refusals.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
- FROM silent TO `rls-predicate-unenforceable`: `record.f in current_user`, `record.f.startsWith(current_user)`, `.endsWith(current_user)` and `.contains(current_user)`. Name the key that holds the value, for example `record.f in current_user.org_user_ids` or `record.f.startsWith(current_user.email)`.
1515
- FROM silent TO `rls-predicate-unenforceable`: `record.f.startsWith(current_user.org_user_ids)` (a list handed to a string method). Write `record.f in current_user.org_user_ids`, or pass a one-value key.
1616

17-
**A `null` list member or `null` ordering bound.** The platform refuses both in every filter it is sent. The RLS layer does not run that check on its own filter, so the backend answers with its own semantics: on a SQL driver `record.f in [null]`, `!(record.f in ['a', null])` and `record.f > null` read no rows. Every analytics query over the object is refused.
17+
**A `null` list member or `null` ordering bound.** The platform refuses both in every filter it is sent. The RLS compiler runs that check on its own filter too (#20212) and drops the policy on every request: reads return no rows and `check` writes are refused 403.
1818

1919
- FROM silent TO `rls-predicate-unenforceable`: `record.f in ['a', null]` and `!(record.f in ['a', null])`. Write `(record.f in ['a'] || record.f == null)`, or drop the `null` member.
2020
- FROM silent TO `rls-predicate-unenforceable`: `record.f in [null]`. Write `record.f == null`.
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.

‎.changeset/20018-native-read-scope-faces.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ each is refused by a standing ruling the shared faces carry. Measured on SQLite:
4040
analytics face already refused all of them. No in-repo read-scope producer emits
4141
them for a policy in this repository. An RLS `using` predicate can still be
4242
written so that it lowers into the null shapes (a literal `null` inside an `in`
43-
list, or an ordering comparison against `null`), and such a policy now gets the
44-
withheld 500 on every analytics face.
43+
list, or an ordering comparison against `null`), but the RLS compiler drops such a
44+
policy (#20212), so the analytics faces receive the deny sentinel and answer zero rows.
4545

4646
**Fix.** State absence with the null predicate. "One of these values, or no
4747
value" is `{ "$or": [{ "f": { "$in": ["a"] } }, { "f": { "$null": true } }] }`,
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)