Skip to content

Commit 0164eba

Browse files
committed
Merge origin/main (980bc05 and later) into the #20035 branch
Resolves the two test conflicts with #20046 (#20018) so both sets of re-judgements hold: the comparand matrix keeps #20018's read-scope cells and this branch's where-door cells, each named with the change that moved it; the non-string $field case holds on both doors, each in its own envelope. Sentences either PR made false on the merged tree are scoped: the #5234 one-sentence note, the binary predicate note, the comparand-shape.ts binary-extra docblock (#20018's "on the where door, that is"), and this branch's changeset and normalizer lines on the read-scope binary admission. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
2 parents f4ba18b + 980bc05 commit 0164eba

131 files changed

Lines changed: 6634 additions & 883 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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
'@objectstack/cloud-connection': minor
3+
---
4+
5+
fix(cloud-connection): `POST /api/v1/marketplace/install-local` parses the package id it installs through the manifest declaration (#19576)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for callers of the install-local door** — a manifest whose `id` is
10+
not reverse-domain notation is now refused `PLUGIN_MANIFEST_INVALID` (`400` for
11+
an inline manifest, `502` for a cloud-fetched snapshot) before anything is
12+
registered or written. It used to install and answer `200`.
13+
14+
The accept set only shrinks back to what the published declaration has always
15+
said. `MANIFEST_ID_PATTERN` (`@objectstack/spec/kernel`) is the one declaration
16+
of a package id, and the other two package-install doors — `POST
17+
/api/v1/packages` and the protocol install primitive — already refuse the ids it
18+
refuses. This door is a separate path: it never calls the protocol primitive,
19+
so neither gate covered it. It derived the id as `manifest.id ?? manifest.name`
20+
and parsed nothing, so `late-app`, `com.example.my_erp`, a number, or a manifest
21+
carrying only a `name` installed cleanly and became the key for the on-disk
22+
ledger entry and for every `:manifestId` route.
23+
24+
The door now asks the declaration **by reference** — `ManifestSchema.shape.id`
25+
— at the one point where the inline branch (after a compiled bundle is
26+
flattened) and the cloud branch have converged, ahead of the `409
27+
MANIFEST_CONFLICT` collision check, the posture gate, the hot-register and the
28+
ledger write. The sentence the caller reads is the declaration's own
29+
(`manifestIdRefusal`), surfaced rather than reworded. Posting `id: 'late-app'`
30+
now answers, in `error.message`:
31+
32+
```text
33+
Invalid package id 'late-app' on `manifest.id`. Expected reverse-domain notation
34+
('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments;
35+
hyphens allowed inside a segment, underscores are not. Did you mean
36+
'com.example.late-app'?
37+
```
38+
39+
**`manifest.name` is no longer read as an id.** `ManifestSchema` declares `id`;
40+
`name` is a display label with no pattern. A manifest with no `id` is refused
41+
with the same sentence, naming `manifest.id`. The inline branch's earlier
42+
message for that case — which said the manifest needed an `"id"` or a `"name"`
43+
— is gone with the fallback it described.
44+
45+
**What is not affected.** A conforming id installs exactly as before, on both
46+
branches and for both the flat and the compiled-bundle shape. Ledger entries
47+
already on disk are not re-judged: an entry an older build installed under an
48+
id the declaration refuses still rehydrates at boot and can still be removed
49+
with `DELETE /api/v1/marketplace/install-local/:manifestId`; only a fresh
50+
install under that id is refused. Boot-time and in-process registration
51+
(`manifest.register`, `AppPlugin`) never passes through this door.
52+
53+
**If you are refused.** Give the manifest an `id` in reverse-domain notation —
54+
lowercase dot-separated segments, hyphens allowed inside a segment, underscores
55+
not. The refusal names the key, echoes what was sent and, where a mechanical
56+
repair exists, offers one it has already checked against the rule. Artifacts
57+
built by `os build` from `defineStack()` already carry a conforming id, because
58+
the same declaration refuses anything else at build time.
59+
60+
<!-- 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. Existing ledger entries are left exactly as they are. The refusal itself carries the remedy. -->
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
A metadata form's option-value refusal now says what to do: `defineForm`'s module-load refusal of an inline option `value` that fails the system-identifier grammar names the derive path, and the form field's `options` describe states the rule it belongs to (#19678, #19907).
6+
7+
Clause-②: no
8+
9+
A form option `value` is a lowercase system identifier — `FormSelectOptionSchema` reuses `SelectOptionSchema.value` by reference — so an enum member carrying a hyphen or a capital (`object.managedBy`'s `system-data`, `action.openIn`'s `new-tab`, `action.execution`'s `perRecord`) cannot be written as an inline option at all. That bound stays. An enum-typed metadata-form row may still carry an inline `options` list, to give its members human labels or to offer a deliberate subset. A row whose members cannot be spelled as option values omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`.
10+
11+
- **The refusal names the remedy.** `defineForm` still throws a `ZodError` at module load with the same issues and codes (`invalid_format` for the pattern, `too_small` for the two-character floor). The grammar message on an inline option's `value` is kept, and now carries the derive path after it, for a row whose members cannot be spelled as option values. Only schema-bound forms built by `defineForm` get this sentence. The grammar message where it is declared (`SystemIdentifierSchema`) is unchanged, because it also bounds object-field options and three object-storage names, where omitting `options` is not the answer.
12+
- **The describe states the rule** on `FormFieldSchema.options`: an inline list is allowed on an enum-typed row, and the derive path is named for a row whose members cannot be spelled. That text is served in the JSON Schema and on the generated reference page.
13+
- ⛔ **No accept-set change.** Every value refused before is still refused, and every value accepted before is still accepted. No key, export or schema shape moves.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the `OS_EAGER_SCHEMAS=1` rollback no longer crashes the `@objectstack/spec/api` and `/data` entries at import
6+
7+
`OS_EAGER_SCHEMAS=1` is the documented emergency rollback of the lazy-schema
8+
memory optimization. With it set, a process whose first spec import was
9+
`@objectstack/spec/api` or `@objectstack/spec/data` threw at import: the bundles
10+
failed with `Cannot read properties of undefined (reading 'optional')`, and the
11+
source with `Cannot access 'FilterConditionSchema' before initialization`. The
12+
default lazy path was not affected.
13+
14+
The cause was an import cycle. `shared/suggestions.zod` held a value import of
15+
`FieldType` from `data/field.zod`, only to feed `suggestFieldType`, and
16+
`shared/strict-object` (which nearly every closed schema imports) imports
17+
`suggestions.zod`. So `data/filter.zod` pulled `field.zod` in before it had
18+
finished loading, and `field.zod`'s eagerly built `FieldSchema` read
19+
`FilterConditionSchema` too early.
20+
21+
`suggestFieldType` now lives in its own module, and `suggestions.zod` imports no
22+
schema module. **Nothing an author or a consumer writes changes:**
23+
`suggestFieldType` is still exported from `@objectstack/spec` and
24+
`@objectstack/spec/shared` with the same signature and the same answers, and no
25+
schema accepts or rejects anything differently. Every published entry now
26+
imports cleanly as the first spec import under the flag, and a test pins that
27+
for each subpath in the `exports` map.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/formula': patch
3+
'@objectstack/objectql': patch
4+
---
5+
6+
fix(formula,objectql): the two refusals a traversing validation rule on an optional lookup meets now name the repairs that work — a `conditional` wrapper or `required: true` (#20007)
7+
8+
Clause-②: no
9+
10+
An author who wants to refuse a write when an OPTIONAL lookup is set and its related record is secret writes `record.line != null && record.line.kind == 'secret'`. Two refusals then sent them in a circle:
11+
12+
1. That expression reads `line` both through the relationship and as a plain value, which cannot be served, and is refused. The refusal said to "compare the id explicitly" and write `record.line.id` for the value comparison.
13+
2. `record.line.id != null && record.line.kind == 'secret'` reads through `line` too, so an order with no line is refused before the rule is evaluated, as "no single related record". That refusal said to "guard the rule on the reference being set" and named no spelling for the guard.
14+
15+
Which writes are refused is unchanged, and so are the error, the `rule_violation` field error and its `constraint` (`reason: 'unevaluable'` and the fault). `@objectstack/lint` passes the formula refusal through unchanged, so it shows the new text too. Only the prescriptions change. Both now name the two spellings measured to work for an optional reference, and the guard is worded exactly as in the delete-cleanup refusal:
16+
17+
```text
18+
… To compare the id, write `record.line.id` for the value comparison, and keep
19+
`record.line.<related field>` for the traversal. `record.line.id` is not a null guard: it
20+
reads through `line` too, and a rule that reads through an empty `line` rejects the write
21+
instead of being skipped. If the plain value tests for empty, take that test out of this
22+
expression. To skip the rule while `line` is empty, guard it on `line` being set: make it
23+
the `then` of a `conditional` rule whose `when` is `record.line != null`. To refuse an
24+
empty `line`, make `line` required (`required: true`).
25+
```
26+
27+
```text
28+
… A predicate resolves ONE hop through a single reference. To skip the rule while `line`
29+
is empty, guard it on `line` being set: make it the `then` of a `conditional` rule whose
30+
`when` is `record.line != null` — `record.line.id != null` inside the rule is no guard, as
31+
it reads through `line` too. To refuse an empty `line`, make `line` required
32+
(`required: true`). For a multi-value reference, test it with a macro (`exists`, `size`)
33+
instead of reading through it.
34+
```
35+
36+
The repair as an author writes it, measured end to end on insert and update. It accepts an order with no line or a public line, and refuses a secret line with the rule's own message:
37+
38+
```ts
39+
validations: [{
40+
name: 'no_secret_line_when_set', type: 'conditional',
41+
message: 'Only checked while the order names a line.',
42+
when: 'record.line != null',
43+
then: { name: 'no_secret_line', type: 'script', message: 'An order may not carry a secret line.',
44+
condition: "record.line.kind == 'secret'" },
45+
}]
46+
```
47+
48+
With `required: true` on `line` instead, an order with no line is refused at the field (`required`), and the rule still judges one with a line.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
fix(plugin-security)!: the Layer 0 tenant write wall now holds for the row a write stores, after the `beforeInsert` / `beforeUpdate` chain (#20013)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) an enforcement change on the write gate: no authorable key, spelling or stored shape moves, so a stored `sys_metadata` row needs no conversion and an upgrader has nothing to hand-edit. The remedy for a newly refused write is to change the hook or the data it derives the organization from. -->
10+
11+
**BREAKING**: this narrows the set of writes the write gate accepts. Under a walled tenancy posture (`isolated` or `group`), a write whose `beforeInsert` or `beforeUpdate` hook sets `organization_id` to an organization outside the caller's organization scope is now refused. It is admitted today, and the row is stored in that organization. It ships as `minor` under the launch-window convention, as the row-level `check` changes did (#19950, #19989).
12+
13+
The Layer 0 tenant wall (ADR-0095 D1, ADR-0105 D5) holds a write's `organization_id` to the same filter the read side uses, so a non-platform user can only place a row in an organization they hold. The wall judged the payload as the caller sent it, before the engine ran the hook chain. A value a hook wrote into `organization_id` after that point was never judged, whether the hook derived it from another field, a parent record or a lookup.
14+
15+
The wall now also judges the row the engine is about to store, through the seam the row-level `check` already uses (`OperationContext.postHookWriteImageCheck`): an insert's rows once the `beforeInsert` chain has run (every row of an array insert), the one row of a by-id update, and every matched row of a predicate update, each merged with the final payload. It is installed whenever the wall applies to the write, with or without a business `check`. The existing judgement of the payload as sent stays, so this change only ever refuses more.
16+
17+
**Writes that are now refused.** Each refusal is the wall's existing denial, `403 PERMISSION_DENIED` ("the insert/update would place '…' in another tenant"), and nothing is stored. There is no transition switch.
18+
19+
- **An insert, a by-id update or a predicate update whose hook chain leaves `organization_id` outside the caller's organization scope**: another organization under `isolated`, one outside the membership set under `group`. For an on-behalf-of write the delegator's scope applies too (ADR-0090 D10).
20+
- **A walled write on a host that installs the judgement and never runs it**, for example a custom write executor in place of the engine. It is refused after the write with an `error` log saying the tenant wall was not evaluated on the stored row, as a write with an uncalled row-level `check` already is. The platform's own permission-set data door (ADR-0094), which executes its writes without the engine and so runs no hook chain, is not affected.
21+
22+
**Remedy.** A hook that must place a row in another organization does so in a separate write under a system context, which the wall does not gate. Otherwise fix the hook, or the data it derives the organization from, so the stored row stays in the caller's organization scope.
23+
24+
**What does not change.**
25+
26+
- A write whose hooks leave `organization_id` in the caller's scope, or leave it alone, is admitted as before. An update that does not touch the column keeps the row's current organization.
27+
- An insert that leaves `organization_id` absent is not judged on it: the platform fills it with the caller's active organization, as before.
28+
- A supplied out-of-scope `organization_id` is refused before anything runs, as before, even when a hook would replace it with an in-scope one.
29+
- A system-context write, a platform administrator on an object whose posture lets them cross the wall, and every write under the `single` posture are not gated by the wall, as before.

‎.changeset/20015-name-field-id.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
"@objectstack/service-automation": patch
4+
"@objectstack/service-messaging": patch
5+
---
6+
7+
fix(plugin-approvals, service-automation, service-messaging): five system objects title their records with a text formula instead of the raw id (#20015)
8+
9+
Clause-②: no
10+
11+
ADR-0079 resolves a record's title as `nameField`, then `displayNameField`, then a derivation, and an explicit `nameField` takes precedence over the render-only `titleFormat`. Five system objects declared `nameField: 'id'` beside a composite `titleFormat`. A renderer that follows ADR-0079's order therefore showed the raw record id as the record page's title for:
12+
13+
- `sys_approval_request`, whose `titleFormat` is `{process_name} · {record_id}`;
14+
- `sys_approval_action`, whose `titleFormat` is `{action} · {step_name}`;
15+
- `sys_approval_approver`, whose `titleFormat` is `{approver} · {request_id}`;
16+
- `sys_automation_run`, whose `titleFormat` is `{flow_name} · {node_id}`;
17+
- `sys_http_delivery`, whose `titleFormat` is `{label} → {url}`.
18+
19+
Each object now declares `display_title`, a formula field with `returnType: 'text'` over the same columns, and points `nameField` and `displayNameField` at it. This is the migration the `titleFormat` schema text prescribes: "a composite to a formula field designated as nameField". The record title is now the text the `titleFormat` described. Where a source column is nullable (`step_name`, `node_id`, `label`), a row without it is titled by the other column alone.
20+
21+
A formula field is computed when a record is read. It adds no database column, so no schema migration runs. Record reads and write responses now carry `display_title`. For these objects the server-side title accessor (`resolveRecordTitle`) now returns the formula's text instead of the raw id.
22+
23+
`titleFormat` stays on all five objects, unchanged, for renderers that still read it first. `$search` resolution is unchanged: a formula field is never a search target, and neither was `id`.

0 commit comments

Comments
 (0)