Skip to content

Commit c28fe19

Browse files
committed
merge origin/main into claude/issue-19227-spec-gate-prerequisite-exit
2 parents 05ac9ee + 8f6d831 commit c28fe19

126 files changed

Lines changed: 2172 additions & 282 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: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": minor
4+
"create-objectstack": minor
5+
---
6+
7+
feat(spec)!: `manifest.id` enforces the reverse-domain rule its registry face already had (#17534)
8+
9+
<!-- adr-0087: registered manifest-id-reverse-domain-required -->
10+
11+
**BREAKING** in the accept-set sense, landing in the launch window as `minor`
12+
(the repo's convention: `major` is refused by `check-changeset-no-major`, and
13+
breaking-ness is carried by this banner plus the ADR-0087 disposition):
14+
`ManifestSchema.id` was `z.string()` and accepted any string. It now enforces
15+
reverse-domain notation — the same rule `PackageSchema.manifestId` has always
16+
carried, now declared once and referenced from both sites so the two cannot
17+
drift again.
18+
19+
Two declarations named one identity and disagreed. The registry enforced the
20+
shape; the key an author actually writes did not. So a package scaffolded,
21+
validated, built and booted with an id the publish path would refuse, and the
22+
author met the rule for the first time at the most expensive possible moment.
23+
24+
FROM → TO, for metadata that used to parse and now fails:
25+
26+
```ts
27+
// FROM — accepted by defineStack, refused at publish
28+
defineStack({ manifest: { id: 'my_app', /* … */ } });
29+
defineStack({ manifest: { id: 'com.acme.my_app', /* … */ } });
30+
31+
// TO — dot-separated lowercase segments; hyphens inside a segment, never underscores
32+
defineStack({ manifest: { id: 'com.example.my-app', /* … */ } });
33+
defineStack({ manifest: { id: 'com.acme.my-app', /* … */ } });
34+
```
35+
36+
The refusal carries the repair rather than restating the rule: it names the key,
37+
echoes the value, shows both documented examples, and — having first checked the
38+
candidate against the pattern itself — suggests `com.example.blank` for a bare
39+
word and `com.dogfood.flow-fixture` for a value whose only fault is an
40+
underscore. A suggestion it cannot verify it does not make.
41+
42+
⚠️ **Changing an id is a republish, not an edit.** An id is an identity: the
43+
registry addresses a package by `manifest_id`, an installed row is keyed on it
44+
and a dependent declares it. Before renaming, confirm nothing still addresses
45+
the old value. That is why this ships as an ADR-0087 **semantic** entry
46+
(`manifest-id-reverse-domain-required`) with a structured TODO and no automatic
47+
rewrite — `objectstack migrate meta` will not rename an id for you.
48+
49+
`manifest.namespace` is unchanged and still admits underscores, so the two are
50+
derived from a project name under different rules and neither is the other. Both
51+
scaffolders were producing ids the new rule refuses and both now derive a
52+
conforming one: the bundled `create-objectstack` template ships
53+
`com.example.blank` and interpolates `com.example.<project-name>` in kebab form,
54+
and `os init` derives its id from the project name instead of interpolating the
55+
snake_case namespace (`os init my-app` produced `com.example.my_app`).
56+
57+
## ⚠️ One consent path reverses direction: fail-OPEN → fail-CLOSED
58+
59+
Narrowing `manifest.id` also narrows the **accept set of the artifact load
60+
path**, and on one route that is a **fail-OPEN → fail-CLOSED reversal on a
61+
consent/permission path**. Stating it explicitly because a reversal in that
62+
direction is owed a named direction and a named population, however small the
63+
population turns out to be.
64+
65+
**What changed.** `AssembledPackageBodySchema` extends `ManifestSchema`, so the
66+
artifact package entry schema now carries this rule too. An assembled package
67+
whose `manifest.id` is `''` used to parse: `artifactPackageId` is
68+
`manifest.id || manifest.name`, so such a package was carried under its `name`,
69+
while an install-time `grantedPermissions` record keyed by `''` matched no
70+
carried package and was registered nowhere. The package loaded **with no
71+
consent record at all** — reported as unbound, warned about, and otherwise
72+
allowed to run. That is the fail-OPEN half. Such an entry is now refused
73+
outright (`INVALID_ARTIFACT_PACKAGE_ENTRY`, 422) and the artifact does not
74+
materialize at all — fail-CLOSED.
75+
76+
**Who is affected: artifacts carrying `manifest.id: ''`, and they were already
77+
half-broken in both directions.**
78+
79+
- They could never be **published**: the registry face
80+
(`PackageSchema.manifestId`) has carried this exact pattern all along — the
81+
same regex literal, now the shared `MANIFEST_ID_PATTERN` — so the publish path
82+
has always refused them.
83+
- Their granted-permissions **consent already did not apply**: a record keyed by
84+
`''` bound to nothing, silently, on every load.
85+
86+
⇒ For that population this converts a silent, already-ineffective consent
87+
binding into an explicit refusal that names `manifest.id`. Nobody who could
88+
publish an artifact loses the ability to load it; what they lose is a shape that
89+
only ever half-worked.
90+
91+
⛔ This is the **artifact package door** refusing a malformed id, **not** the
92+
permission enforcer acquiring teeth. The install-time granted permission set is
93+
still registered and not enforced (#17147) — nothing on the tree queries that
94+
registry, and the repo-wide pin asserting so is unchanged and still green.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/spec': minor
4+
---
5+
6+
fix(plugin-security): the `sys_permission_set` duplicate-name refusal carries `UNIQUE_VIOLATION`, and the packaged-set lock answers first (#19307)
7+
8+
Clause-②: yes
9+
10+
Two halves of one defect on the data door's insert leg for `sys_permission_set`
11+
(`permission-set-projection.ts`), both measured live on `examples/app-showcase`
12+
with a seeded admin over a cookie session.
13+
14+
**1. The refusal carried no machine-readable code.** It threw a bare `Error`
15+
with `.status = 409` and no `.code`, and the flat `{ error, code }` responder
16+
invents nothing for a producer that declared nothing, so the client got prose:
17+
18+
```
19+
POST /api/v1/data/sys_permission_set {"name":"dev_local_set"}
20+
→ 409 {"error":"[Security] permission set 'dev_local_set' already exists","object":"sys_permission_set"}
21+
```
22+
23+
ADR-0112's 2026-08-17 amendment closed `error.code` at the flat door too, so a
24+
409 with no code is that contract unhonoured — and a UI that has to branch on
25+
the refusal was pushed back to string-matching. The same request now answers
26+
`409 … "code":"UNIQUE_VIOLATION"`, message byte-identical.
27+
28+
⚠️ `UNIQUE_VIOLATION` is REUSED, not minted. `sys_permission_set` declares
29+
`{ fields: ['name'], unique: 'organization' }`, so this very collision already
30+
answers `409 UNIQUE_VIOLATION` when the index catches it instead of this
31+
pre-check; a second spelling would make one condition answer two envelopes
32+
depending only on which layer got there first. The ledger gains a provenance
33+
row for `@objectstack/plugin-security` — the union, its casing and every other
34+
package's rows are unchanged, and no schema shape moves.
35+
36+
**2. It ran BEFORE the packaged-set lock, so the most likely path answered the
37+
less useful of two true refusals.** A package-declared set has a projected row,
38+
so its name is duplicate AND locked at once. An admin who opened the Clone
39+
dialog on a packaged set and typed the base set's own name — the single most
40+
likely thing to type — got `already exists`, which names no remedy, and never
41+
reached `NOT_OVERRIDABLE`, which names the clone path. The lock now runs first:
42+
43+
```
44+
POST /api/v1/data/sys_permission_set {"name":"showcase_manager"}
45+
→ 403 {"error":"[Security] Permission set 'showcase_manager' is declared by package
46+
'com.example.showcase' and is locked … Choose a different name for your set, or clone
47+
'showcase_manager' …","code":"NOT_OVERRIDABLE","object":"sys_permission_set"}
48+
```
49+
50+
**What did NOT move**, measured on the same runtime: an ordinary
51+
(non-package-declared) duplicate **whose provenance the lock can resolve** still
52+
answers the duplicate refusal and not `NOT_OVERRIDABLE` — that qualifier is
53+
load-bearing, and the corner below is the case it excludes; an unauthenticated
54+
write on the same resource still answers `401 UNAUTHENTICATED`; and an `update`
55+
targeting a packaged set answers `403 NOT_OVERRIDABLE` exactly as before.
56+
57+
⚠️ **One corner moved with the order**: an ordinary duplicate attempted while no
58+
artifact source can answer now takes the lock's fail-closed `unknown` refusal —
59+
`403` `NOT_OVERRIDABLE` (`PackagedPermissionSetProvenanceUnknownError`, "retry
60+
once the metadata layer is readable") — instead of the 409. Both are refusals and
61+
neither writes; it is pinned so the behaviour is declared rather than incidental.
62+
63+
⚠️ **And the order has a cost, stated rather than discovered**: the lock's probe
64+
(`protocol.getMetaItemLayered`) used to be evaluated only AFTER the duplicate
65+
check passed, so a duplicate insert never paid for it. It is now evaluated
66+
unconditionally, ahead of that check. Two consequences, both deliberate: every
67+
**duplicate** insert on `sys_permission_set` costs one extra metadata round trip
68+
(the accepted path's cost is unchanged — it always paid this probe), and the
69+
duplicate path is now COUPLED to metadata-layer reachability, where before it
70+
answered from the record alone. That coupling is the mechanism behind the corner
71+
above, and it is the price of putting the refusal that names the remedy first.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
`Clause-②: yes (widening)`
7+
8+
A `percent` field's declared `scale` is the number of decimal places of the **percentage-point** value as displayed and entered; the **stored** allowance now derives from it. For a fraction-stored percent the record validator's `max_scale` branch accepts `scale + 2` decimal places in the stored fraction (#19320).
9+
10+
Maintainer ruling batch #161 item 3 letter B (2026-09-18) settles what one word means: `scale: 2` on a percent field is two displayed decimals, so the edit widget offers `12.34` and writes the fraction `0.1234`. The branch compared those four places against the raw declaration and refused the write — an author could declare two displayed decimals and then not write two displayed decimals.
11+
12+
- **Which fields move**: only a **fraction-stored** percent, i.e. one whose `percentScaleOf` is `fraction` — no declared `max`, or a `max` at or below 1. A **whole-percent** field (`max` above 1) stores the displayed number itself and keeps the declared `scale` exactly, as do `number`, `currency`, `slider` and `rating`. The split is read from the spec's `percentScaleOf`, not re-decided at this seam.
13+
- **Direction, measured in both**: over a 1,950-cell corpus of declaration x written value, **36 cells move from refused to accepted and 0 move the other way**. Nothing that writes today stops writing; no stored value is re-read or re-judged; no migration is implied.
14+
- **`FieldSchema.scale`'s describe states both meanings**, which is the half of the ruling that makes the derivation legible to an author: what the number counts (displayed percentage points) and what it permits in storage (`fraction` ⇒ `scale + 2`, `whole` ⇒ `scale`). The generated field reference page carries the same sentence, and `percentScaleOf`'s docblock points at it rather than restating it.
15+
- **The refusal envelope names the allowance that was applied.** On a fraction-stored `scale: 2` field, `0.12345` is still refused and reports `constraint: { scale: 4, actual: 5 }` — previously it would have read `{ scale: 2, actual: 5 }` on a field that accepts four places, a true refusal described by a false constraint. A consumer asserting the raw declaration back out of a percent field's `max_scale` envelope reads the derived number instead.

‎.changeset/tidy-moons-repeat.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
`POST /api/v1/data/{object}/batch` with `operation: "delete"` stops reporting a deletion it
6+
did not perform.
7+
8+
This is the third and last of the by-id delete doors to read the engine's answer — the
9+
single-record `DELETE` and `deleteMany` already do. Each row of the batch answered
10+
`success: true` for every engine result that was not the driver contract's `false`. The
11+
`false` arm — "no row matched" — has reported honestly since #4435 and #5088; the other
12+
ending had not: a row that MATCHED and was deliberately NOT removed was counted in
13+
`succeeded` and reported as deleted, byte-identical to a real deletion.
14+
15+
`sys_permission_set` is the shipped shape of it. Deleting a package-declared set is an
16+
ADR-0005 RESET: the overlay tombstones and the record re-projects to the declared body
17+
instead of vanishing. That behaviour is unchanged and deliberate — what was wrong is the
18+
answer, and on a security-configuration write it told an operator a permission set was gone
19+
while it was still being enforced.
20+
21+
`IDataEngine.delete` declares `Promise<boolean | number>` — the driver boolean for a by-id
22+
write, a count of rows removed otherwise — so a numeric zero is the one value that positively
23+
means the record is still there. The per-row `success` now reads that count instead of being
24+
a literal.
25+
26+
On the wire, for one row of a `delete` batch:
27+
28+
- removed — `success: true`, counted in `succeeded` (unchanged);
29+
- matched but not removed — `success: false`, counted in `failed`, and no `errors[]` entry:
30+
a surviving record is an outcome, not a fault, and this envelope's two per-row codes
31+
(`ROLLED_BACK`, `NOT_ATTEMPTED`) both describe a row that never ran;
32+
- unknown id — `success: false` with `errors[0].code: RECORD_NOT_FOUND` (unchanged);
33+
- an off-contract `undefined` from a third-party driver keeps its #4435 reading.
34+
35+
Because `succeeded` and `failed` partition `results`, a surviving row also makes the
36+
request-level `success` false, and an `atomic` batch containing one now rolls back rather
37+
than committing under a response that called every row deleted. A non-atomic batch is not
38+
stopped by it: nothing was thrown, so the `continueOnError` stop does not apply and the
39+
remaining records are still attempted. `returnRecords: false` keeps `success`, so the honest
40+
value survives that projection too.
41+
42+
Clause-②: no

‎.claude/settings.json‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,11 @@
4949
"Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/objectstack/pulls/* *)",
5050
"Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/objectui/pulls/* *)",
5151
"Bash(curl -sS -X POST https://api.github.com/repos/objectstack-ai/objectstack/pulls/*/ccr/ready_for_review *)",
52-
"Bash(curl -sS -X PUT https://api.github.com/repos/objectstack-ai/objectstack/pulls/*/ccr/auto_merge *)"
52+
"Bash(curl -sS -X PUT https://api.github.com/repos/objectstack-ai/objectstack/pulls/*/ccr/auto_merge *)",
53+
"Bash(node --use-env-proxy scripts/pm/post-stamped.mjs *)",
54+
"Bash(node --use-env-proxy scripts/pm/label-write.mjs *)",
55+
"Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/objectstack/issues/* *)",
56+
"Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/objectui/issues/* *)"
5357
],
5458
"deny": [
5559
"mcp__github__issue_write",

‎AGENTS.md‎

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -931,16 +931,26 @@ restores durability, or the explicit opt-out that makes the degradation delibera
931931
(`suspendedRunStore: 'memory'`, `OS_SKIP_SCHEMA_SYNC`). Say it **once**, at the first
932932
degradation, not once per failed write.
933933

934-
**Do not over-apply it.** Escalating a functional degradation to `error` trains
935-
everyone to skim `error`. An `if (!service)` composition branch is usually functional
936-
and belongs at `warn`; a `catch` around a write, a DDL call, or a store initialization
937-
is where this rule bites. **And a failure handed to the CALLER is not a degradation at
938-
all** — the third legal answer: a `catch` that answers `errorFromThrown(e, 400)`, or a
939-
batch whose contract IS a per-item outcome report, does not look normal from the
940-
outside — the requester was told. Do **not** bolt a `logger.error` onto such a site;
941-
declare **how it delivers** instead — `FAILURE_PROPAGATION_CALLEES` (repo-wide names)
942-
or the function-scoped `FAILURE_PROPAGATION_SITES` in the checker, which then proves
943-
structurally that *every* path out of the `catch` delivers.
934+
**Do not over-apply it.** Escalating a functional degradation to `error` trains everyone
935+
to skim `error`. An `if (!service)` composition branch is usually functional and belongs
936+
at `warn`; a `catch` around a write, a DDL call, or a store initialization is where this
937+
rule bites. **And a failure handed to the CALLER is not a degradation at all** — the
938+
third legal answer: a `catch` that answers `errorFromThrown(e, 400)`, or a batch whose
939+
contract IS a per-item outcome report, does not look normal from the outside — the
940+
requester was told. Do **not** bolt a `logger.error` onto such a site; declare **how it
941+
delivers** instead — and which declaration exists depends on how the failure leaves the
942+
`catch`. Delivered by CALLING something: name that callee, repo-wide in
943+
`FAILURE_PROPAGATION_CALLEES` or function-scoped in `FAILURE_PROPAGATION_SITES`, and the
944+
checker then proves structurally that *every* path out of the `catch` reaches it.
945+
Delivered by **RETURNING** an outcome object — `return { ok: false, error }`, a `failed`
946+
receipt — there is **no callee to declare**: the delivery IS the constructed value, and
947+
both maps key on a name. ⛔ Never add such a seam to `DURABILITY_CRITICAL_CALLEES` or to
948+
either `FAILURE_PROPAGATION_*` map, ⛔ never baseline it, ⛔ never bolt on a
949+
`logger.error` to green the checker: the shape is outside its reach by construction. Pin
950+
it in **its own file's test** asserting the returned failure outcome (the
951+
`failed`-receipt case is the reference shape), and read its population from
952+
`scripts/measure-return-propagating-durability-seams.mjs`. ⭐ The invariant does not
953+
move: silent data loss must be loud, so what this checker cannot see, that test holds.
944954

945955
**It has teeth**: `pnpm check:durability-log-level`
946956
(`scripts/check-durability-degradation-log-level.mjs`; its header is the authority)

‎content/docs/api/declarative-endpoints.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import { defineStack } from '@objectstack/spec';
4747

4848
export default defineStack({
4949
manifest: {
50-
id: 'acme-crm',
50+
id: 'com.acme.crm',
5151
name: 'Acme CRM',
5252
version: '1.0.0',
5353
type: 'app',

‎content/docs/api/metadata-api.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,16 +105,16 @@ List installed packages.
105105
Install a package from its manifest (SDK: `client.packages.install`). Re-installing an
106106
already-installed `id` returns **409 Conflict** unless `overwrite: true`.
107107

108-
**Body**: `{ manifest: { id: "plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, settings?: { ... }, enableOnInstall?: true, overwrite?: false }`
109-
**Response**: `{ package: { id: "plugin-auth", version: "1.0.0", ... }, message?: "..." }`
108+
**Body**: `{ manifest: { id: "com.objectstack.plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, settings?: { ... }, enableOnInstall?: true, overwrite?: false }`
109+
**Response**: `{ package: { id: "com.objectstack.plugin-auth", version: "1.0.0", ... }, message?: "..." }`
110110

111111
### `POST /packages/publish`
112112

113113
Publish a package (manifest + metadata) to the package marketplace registry. This is
114114
publisher tooling, not part of the app SDK surface.
115115

116-
**Body**: `{ manifest: { id: "plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, metadata: { objects: [...], views: [...], ... } }`
117-
**Response**: `{ success: true, message: "...", package: { id: "plugin-auth", version: "1.0.0" } }`
116+
**Body**: `{ manifest: { id: "com.objectstack.plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, metadata: { objects: [...], views: [...], ... } }`
117+
**Response**: `{ success: true, message: "...", package: { id: "com.objectstack.plugin-auth", version: "1.0.0" } }`
118118

119119
### `GET /packages/:id`
120120

‎content/docs/getting-started/your-first-project.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ import * as objects from './src/objects/index.js';
109109

110110
export default defineStack({
111111
manifest: {
112-
id: 'my-app',
112+
id: 'com.example.my-app',
113113
namespace: 'my_app',
114114
version: '0.1.0',
115115
type: 'app',

‎content/docs/protocol/kernel/http-protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1179,7 +1179,7 @@ import { defineStack } from '@objectstack/spec';
11791179

11801180
export default defineStack({
11811181
manifest: {
1182-
id: 'acme-crm',
1182+
id: 'com.acme.crm',
11831183
name: 'Acme CRM',
11841184
version: '1.0.0',
11851185
type: 'app',

0 commit comments

Comments
 (0)