Skip to content

Commit 215d17e

Browse files
committed
Merge origin/main into claude/issue-6190-reject-org-scoped-write
Conflict: packages/objectql/src/protocol-meta.test.ts — union of #5488's inverted verdict (NOT_CREATABLE 403; api is code-only) and #6973's env-wide re-spell (organizationId removed). Verified: the merged protocol.ts equals main + exactly this branch's delta, so #6710's authoring-channel re-key and #6190's orgScopedWriteRefusal both survive intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LGRN2cSRfggfX9B2L83bQc
2 parents 692f617 + fa6b436 commit 215d17e

317 files changed

Lines changed: 22882 additions & 1938 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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
ADR-0094 D5-R: retire the "customize packaged permission sets through an ADR-0005 env
6+
overlay" direction (2026-07-14), and make the ADR text and the
7+
`permission-set-projection.ts` header agree with what is enforced.
8+
9+
`#6483` (PR #6608) rolled `permission` back to `allowOrgOverride: false`, so a metadata
10+
write against a **code-declared (artifact-backed)** permission set is refused with 403
11+
`NOT_OVERRIDABLE` — ADR-0005's security row ("overlays would create silent privilege
12+
drift") is enforced again. The supported channel for those sets is the one ADR-0086
13+
always named: edit the package and re-publish. Environment authoring survives on the
14+
`allowRuntimeCreate` tier, for sets whose definition lives only in `sys_metadata`
15+
(data-door creations, and package sets authored + published through the metadata door);
16+
that tier edits the single stored definition in place and is deliberately **not**
17+
described as a re-route of the retired overlay channel.
18+
19+
No behaviour change: the four production write points keep their current dispositions.
20+
The refusal is left to the producer — `plugin-security` does not re-derive
21+
artifact-backing to pre-empt it — and the two write points that catch a failed metadata
22+
write (the `restore` leg and the boot backfill) keep reporting on the durability channel.
23+
What changes is prose, plus test coverage that can now see the gate: the suite's protocol
24+
stub models ADR-0005's tier gate, so the four cases that pinned the retired direction no
25+
longer pass for want of a stub that could refuse.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/objectql': major
4+
---
5+
6+
refactor(spec,objectql)!: retire `AggregationNode.distinct` — one face honoured it, five ignored it, and the same query answered two plausible numbers (#6815, ADR-0049)
7+
8+
<!-- adr-0087: registered aggregation-node-distinct-retired -->
9+
10+
**FROM → TO:** `{ function: 'count', field: 'x', distinct: true, alias: 'a' }` →
11+
`{ function: 'count_distinct', field: 'x', alias: 'a' }` — the deduplicating spelling
12+
every backend computes, lowered to `COUNT(DISTINCT x)` on both SQL faces since #6409.
13+
`{ function: 'sum' | 'avg' | 'min' | 'max', …, distinct: true }` → delete the key; there is
14+
no replacement, because no SQL backend ever computed `SUM(DISTINCT …)` here and the
15+
in-memory fallback was the only thing that did. `distinct: false` → delete the key; it
16+
selected the behaviour that is now the only behaviour.
17+
18+
`AggregationNode.distinct` was read by exactly ONE of the six faces that consume an
19+
`aggregations[]` entry. `objectql`'s in-memory fallback (`in-memory-aggregation.ts`)
20+
deduplicated the values before applying the function; `SqlDriver.aggregate`, the Turso
21+
`RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s
22+
`computeAggregate` and `service-analytics`' `AGGREGATE_SQL` all ignored it. So
23+
`{ function: 'sum', field: 'amount', distinct: true }` returned a deduplicated sum when the
24+
engine fell back in memory and an ordinary sum on every SQL datasource — one query, two
25+
numbers, chosen by which backend answered. The engine picks that path per query (a driver
26+
without native aggregation, a non-UTC date bucket, a partial SQL driver), so the number
27+
could move under a dashboard with nothing changing in the query.
28+
29+
That is the divergence class #6203 and #5907 each closed on the aggregate axis, still open
30+
on this key, and it is worse to leave: both answers are plausible NUMBERS rather than a
31+
refusal, so nothing surfaced it. It survived the #4286 sweep of this same schema because
32+
that sweep asked which members no executor reads — the wrong question for a key whose
33+
defect is *which* executor reads it.
34+
35+
REMOVE rather than ENFORCE, per the maintainer ruling of 2026-08-09: `count_distinct`
36+
already covers the only deduplicating spelling with measured demand and took ADR-0049's
37+
enforce leg in #6409, while `SUM(DISTINCT …)` / `AVG(DISTINCT …)` are near-universally a
38+
modelling mistake and would have to be lowered across five faces — two of them frozen under
39+
#5499 — to buy it.
40+
41+
The retirement kit:
42+
43+
- **Tombstone, not deletion** (`retiredKey()`): `AggregationNodeSchema` is not `.strict()`,
44+
so a plain delete would let existing queries parse clean and lose the key in silence
45+
(#3733, ADR-0104) — trading a divergent flag for an ignored one. Authoring it is now a
46+
`tsc` error at the call site and a parse error carrying the prescription. One tombstone
47+
covers every aggregation door: `QuerySchema.aggregations` and
48+
`EngineAggregateOptionsSchema.aggregations` both reuse that one schema by reference.
49+
- **ADR-0087 D3 `SemanticMigration`** (`aggregation-node-distinct-retired`) plus the exact
50+
`RETIRED_KEYS_BY_MAJOR[17]` entry `data/AggregationNode:distinct`. No D2 conversion,
51+
deliberately: `QueryAST` is a request surface — the client SDK builder's output and the
52+
`POST /data/:object/query` body — never stored in stack metadata, so there is no source
53+
for `os migrate meta` to rewrite. That is the disposition every other `data.query.*`
54+
retirement in this major already takes (#4286).
55+
- `objectql`'s in-memory fallback loses its `collectValues` dedupe limb — the whole runtime
56+
cost of the removal. **The observable numbers change on that one path, and that is the
57+
point:** a `sum`/`avg` that used to be deduplicated there now answers what every SQL face
58+
has always answered for the same query. Verify against the SQL answer, not against the
59+
pre-upgrade fallback answer — the two disagreed.
60+
- Measured blast radius inside the fallback, narrower than the key suggests: only `sum` and
61+
`avg` ever changed answer. `count` returned from its own branch before reaching the
62+
dedupe, `count_distinct` fed the values into a `Set` (dedupe-then-`Set` is `Set`), and
63+
dedupe does not move `min`/`max`.
64+
- `POST /api/v1/data/:object/query` answers `400 VALIDATION_FAILED` with a `fields[]` entry
65+
at `aggregations.<i>.distinct` instead of serving a number — the #3899 entry validation
66+
descending into the array, pinned in the REST request-schema conformance gate.
67+
- Liveness ledger (`query.json` `aggregations.children.distinct` → `dead`, README counts),
68+
generated baselines (`authorable-surface/data.json` gains `[RETIRED]`),
69+
`spec-changes.json`, the upgrade guide and the reference docs regenerated.
70+
71+
`count_distinct` is untouched and remains the live deduplicating spelling.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/metadata-protocol': major
4+
'@objectstack/objectql': major
5+
---
6+
7+
refactor(spec)!: `api` is code-only — withdraw a runtime create door the endpoint matcher could never read (#5488, ADR-0049 remove side)
8+
9+
<!-- adr-0087: registered api-runtime-create-withdrawn -->
10+
11+
**FROM → TO:** `PUT /api/v1/meta/api/{name}` (200 "Saved") → declare the endpoint as a
12+
stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) and ship it through
13+
`publishPackage`. The runtime write now answers **403 `NOT_CREATABLE`**, in `?mode=draft`
14+
as well as direct-active. The artifact route is **unchanged** — a `**/*.api.ts` file valid
15+
before this release is valid after it, byte for byte.
16+
17+
`DEFAULT_METADATA_TYPE_REGISTRY`'s `api` entry declared `allowRuntimeCreate: true` and the
18+
runtime never honoured it. Measured on a real showcase boot (`objectstack dev --fresh`, 47
19+
plugins):
20+
21+
```
22+
PUT /api/v1/meta/api/e8_backdoor → 200 {"success":true,…,"message":"Saved …"}
23+
GET /api/v1/apps/showcase/backdoor → 404 (anonymous AND authenticated)
24+
```
25+
26+
…and **no** `[EndpointMatcher] … EXCLUDED` line anywhere in the boot log: the endpoint was
27+
not gated out, it was never in the index at all. The serving criterion belongs to
28+
`IMetadataService.matchEndpoint` → `EndpointMatcher` → `MetadataManager.listForIndex('api')`,
29+
which reads the manager's own registry plus its registered loaders
30+
(`["filesystem","memory"]` on dev/serve). A runtime write lands in `sys_metadata`, which is
31+
in neither. So the declaration promised a capability that could not exist.
32+
33+
A declared-but-unhonoured capability is ADR-0049 false compliance, and "answers Saved, then
34+
404s forever" is its most dangerous shape for the AI authors ADR-0033 targets. The
35+
maintainer ruled REMOVE on 2026-08-07 rather than converge the read path: making the matcher
36+
read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3
37+
miss-vs-outage distinction on a new read path, and there is no business pull for
38+
Studio-authored endpoints today — 17.x serves declarative endpoints through stack artifacts,
39+
which is what showcase uses (#5040 E8, LIVE).
40+
41+
## The retirement kit
42+
43+
- **`allowRuntimeCreate: false`** on the `api` registry entry. With `allowOrgOverride`
44+
already `false`, the type is now **code-only** — the `job` / `agent` / `capability` shape —
45+
so the existing #5086 inlet refuses before persistence, on every kernel, with
46+
`code: 'NOT_CREATABLE'`, `status: 403` and a prescription derived from the entry's own
47+
`filePatterns[0]`. No new refusal mechanism was written for this.
48+
- **`gateApiDraftsForPublish` is retired** (`metadata-protocol`), together with its nine
49+
tests and the `PUBLISH_DRAFTS_NAMESPACE_REMEDY` string only it appended. It landed two
50+
days earlier in PR #5279 and is removed **deliberately and on the record**, not lost in a
51+
refactor: it gated a draft→active promotion into a state the matcher can never read, and
52+
with the inlet closed no `api` draft can exist for it to judge. The in-place comment at
53+
its old call site carries the reasoning.
54+
- **The `metadata-plugin.zod.ts` decision block is rewritten as a recorded overturn.** It
55+
used to record CODE-ONLY as "considered and rejected"; its three bullets are kept verbatim
56+
with what became of each, so the reversal is auditable rather than silently contradicted.
57+
- **The `api` create seed is removed** and `api` joins `KNOWN_UNSEEDED`. A pre-filled "New
58+
API Endpoint" form whose save can only 403 is the UI half of the same false compliance.
59+
- **Pins, not deletions.** The two #5271 tripwire pins that asserted
60+
`allowRuntimeCreate: true` are **replaced** by retirement pins asserting the new verdict —
61+
their comments predicted this exact consequence, and both predictions were correct. Every
62+
rejection case asserts `code` **and** `status` (ADR-0112 envelope), never `toThrow()`
63+
alone (#6142).
64+
65+
## What did NOT change
66+
67+
`validateApiEndpointDeclarations` / `identityFreeEndpointGateFailure` remain the one judge
68+
of what is servable, on the route that serves: the stack schema, `publishPackage` (#5189),
69+
and again at load in `buildEndpointIndex` (PR #5203). ADR-0121's "publish REJECTS" ruling is
70+
intact. `deleteMetaItem` stays ungated so pre-existing rows can be cleaned up, and
71+
`OS_METADATA_WRITABLE=api` remains the single operator escape hatch — note it unlocks the
72+
**write** only; the endpoint still will not be served, which is why it is a diagnostic
73+
rather than a workaround.
74+
75+
**Re-entry path**, recorded by the ruling: if #2657 Part B promotes `apis` to a registered
76+
type **with a real consumption path**, the flag and the publish gate come back together —
77+
implementation first, declaration second.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/rest": minor
4+
"@objectstack/runtime": minor
5+
---
6+
7+
fix(spec,rest,runtime)!: the ADR-0045 publish gate gets its own machine-managed key — `app.hidden` goes back to meaning navigation, and the built-in Account app stops 404ing for every normal user (#4829)
8+
9+
<!-- adr-0087: registered app-hidden-to-unpublished -->
10+
11+
**FROM → TO:** nothing to rewrite by hand. `app.hidden` keeps its spelling and its
12+
authoring contract; the publish gate moves to a new machine-managed key,
13+
`app._unpublished`, which no author writes. Stored `sys_metadata` app rows carrying
14+
`hidden: true` are rewritten to `_unpublished: true` by the ADR-0087 conversion
15+
`app-hidden-to-unpublished` — automatically on every stored-row read, and in place via
16+
`os migrate meta --stored --apply`.
17+
18+
## The defect
19+
20+
`filterAppForUser` (`@objectstack/rest`) treated `app.hidden` as an access gate:
21+
22+
```ts
23+
if (item.hidden === true && !sysPerms.has('studio.access') && !sysPerms.has('setup.access')) return null;
24+
```
25+
26+
`hidden` does not mean that. Its contract, written in `app.zod.ts` the day the key was
27+
born alongside the built-in Account app, is navigation presentation: *"Hidden apps stay
28+
fully routable and permission-checked"* — keep it out of the App Switcher, surface it from
29+
the avatar menu, which is exactly how personal-settings apps behave in GitHub Settings,
30+
the Google account chip and Salesforce Personal Settings.
31+
32+
So the platform's own `account` app — authored `hidden: true` on purpose — was erased from
33+
`GET /api/v1/meta/app` for every user without `studio.access` / `setup.access`. Clicking
34+
the avatar → Profile landed on *"App not available — it may still be publishing"*, and
35+
password changes, avatar, linked accounts, active sessions and the inbox were all
36+
unreachable. Any admin saw a completely healthy system, which is why it survived a release
37+
candidate and shipped a downstream workaround.
38+
39+
The two contracts arrived from different places. ADR-0045 §3 did not introduce `hidden`; it
40+
**borrowed** it, citing an "ADR-0019 launcher contract (`hidden`, `active`)" as an existing
41+
read side. That contract does not exist — **ADR-0019 contains no `hidden`** and never
42+
discussed launchers, the avatar menu or the Account app. The reference was dangling from
43+
the day it was written, which is why nothing caught the collision it created: one boolean,
44+
two contracts, disagreeing on the only question that matters — *may a normal user reach
45+
this app?*
46+
47+
## What changed
48+
49+
- **`AppSchema` declares `_unpublished`** — the ADR-0045 §3 publish gate. `true` means the
50+
app is unpublished: externally unobservable, not merely unlisted. It is written by the AI
51+
additive-materialization path and cleared by `POST /packages/:id/publish-drafts`, and its
52+
`_` prefix is this repo's existing marker for the channel tooling stamps onto artifacts
53+
(ADR-0010's `_lock` / `_provenance` envelope; the prefix `lintAuthoredRecordKeys` already
54+
skips). It is *declared* rather than omitted because the write path validates against
55+
this very schema (`saveMetaItem` → 422; `Registry.validate('app', …)` → `AppSchema.parse`),
56+
so an undeclared key would make the platform's own flip unwritable. The strict door
57+
answers the author-shaped spellings — `unpublished`, `published`, `draft` — with a
58+
prescription that says *publish state is not authorable*, rather than routing them onto
59+
the key.
60+
- **`app.hidden` is navigation only**, and its docblock now says so with the incident
61+
attached. Authoring `hidden: true` affects the App Switcher and nothing else.
62+
- **The REST gate judges `_unpublished`.** A hidden app is served to everyone, with its
63+
`hidden` flag intact so the shell can place it; an unpublished app still 404s externally
64+
and still reaches builders for direct-URL preview, and `requiredPermissions` still applies
65+
to both.
66+
- **`publish-drafts` clears `_unpublished`** instead of un-hiding. It writes `false` rather
67+
than deleting the key, because ADR-0045 §3 makes publish/unpublish symmetric, and it
68+
copies `hidden` through untouched — publishing no longer rewrites a presentation choice
69+
as a side effect. The response fields keep their `unhiddenApps` / `unhideError` spelling:
70+
they are a wire contract read by the objectui Publish button, and renaming them from a
71+
repo that cannot update that consumer would be a silent break of exactly the kind this
72+
change is about.
73+
- **ADR-0045 is amended**, its dangling ADR-0019 reference corrected, and both
74+
implementation sites (`rest-server.ts`, `runtime/domains/packages.ts`) are now anchored in
75+
`scripts/adr-anchors.json` — neither carried an anchor before, which is why an author
76+
could change ADR-0045's §3 without knowing they were changing a decision.
77+
78+
## Why a new key rather than deleting the gate
79+
80+
Taking `hidden` out of the access decision was proposed first and refused. The gate is §3 of
81+
an **Accepted** ADR with pin tests and a live implementation behind it, so removing it in a
82+
patch would reverse a recorded decision by side effect. It is also the worse failure
83+
direction: a gate that fails **open** exposes a half-built app to real users, silently.
84+
85+
## Migration reach
86+
87+
The conversion is `retiredFromLoadPath: true`, and here that flag is load-bearing rather
88+
than bookkeeping — it confines the rewrite to **stored rows**. `hidden` is not retired as an
89+
authorable key, so a conversion running on the load path would rewrite
90+
`defineApp({ hidden: true })`, and the Account app itself, into unpublished apps and
91+
reproduce the defect through the conversion layer. Excluded from the load path, it replays
92+
only where the old meaning is the only meaning: the stored-row rehydration seams and
93+
`os migrate meta`. Stored `hidden: true` was unambiguous under the old regime — that value
94+
*was* the gate, so nobody stored it to mean "keep me out of the switcher"; code-declared
95+
apps like `ACCOUNT_APP` never enter `sys_metadata`, and the Studio app form has no `hidden`
96+
control.
97+
98+
## Follow-ups (other repos, filed separately)
99+
100+
- **cloud** — the AI materialization write point must stamp `_unpublished: true` where it
101+
stamps `hidden: true` today.
102+
- **objectui** — the Unpublished banner and the Publish button must read/clear
103+
`_unpublished`; the App Switcher keeps reading `hidden`, which now means only what it says.
104+
- **os-project-titanwind-ehr** — PLAT-DEF-040's startup `{hidden:false}` overlay can be
105+
deleted once this ships.

0 commit comments

Comments
 (0)