Skip to content

Commit fbbff31

Browse files
Merge branch 'main' into claude/issue-6743-migrate-plan-readonly-open
2 parents b70ca0b + 3e8e669 commit fbbff31

505 files changed

Lines changed: 41431 additions & 5147 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: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec): an action param's `defaultValue` is validated against the param's own value contract (#6970)
6+
7+
`ActionParamSchema.defaultValue` was `z.unknown().optional()`, so a default that
8+
could never satisfy its own param was accepted at authoring time with no warning,
9+
prefilled into the dialog control, and refused only at submit — on a field the
10+
user never touched, by a message that named the param but not the author's
11+
default as the cause.
12+
13+
The default is now checked at parse time through the **same** `valueSchemaFor`
14+
the dispatcher already runs at submit (ADR-0104 D2, `validateActionParams`) —
15+
one rule set, two moments, no second vocabulary. `datetime` was the loudest
16+
instance (`'2026-08-10T15:00'`, a wall clock `datetime-local` happily displays
17+
and `InstantValueSchema` refuses), but the hole was every type: `number` +
18+
`'abc'`, `select` + a non-member, a `multiple` param + a scalar.
19+
20+
The rejection names the param, its type, the offending literal, and why it
21+
matters:
22+
23+
```
24+
Action param "start" (datetime): the default "2026-08-10T15:00" cannot satisfy
25+
this param's own value contract — expected an ISO-8601 instant with explicit
26+
zone (e.g. 2026-03-15T14:30:00.000Z). The dialog would PREFILL this value and
27+
the submit would then be refused with that same message (ADR-0104 D2), for a
28+
field the user never touched …
29+
```
30+
31+
**Acceptance tightening — what is NOT judged.** The gate only answers what the
32+
declaration itself can answer, because an authoring gate that guesses rejects
33+
valid metadata. A param with no `type` of its own keeps an open value shape (the
34+
same default `validateActionParams` applies to an unresolvable type); a
35+
field-backed param that inherits its arity or its option set is not held to
36+
either; and `null` / `''` defaults are skipped exactly as the dispatcher's own
37+
presence check skips them.
38+
39+
**Stock compatibility.** Already-stored action metadata carrying a nonconforming
40+
default keeps loading and keeps working: the read path (`DatabaseLoader.rowToData`)
41+
replays the ADR-0087 conversion chain but runs no Zod validation, and
42+
`MetadataManager.validate` is deliberately a structural check only. Authoritative
43+
spec validation lives on the WRITE path (`protocol.saveMetaItem`) and is surfaced
44+
on reads as the advisory `_diagnostics` envelope — which now reports the
45+
nonconforming default instead of staying silent about it. So this is loud at
46+
authoring, non-fatal at rest, and no conversion is owed: there is no mechanical
47+
rewrite for "the author meant some other instant", and inventing one would pick a
48+
timezone the metadata never declared (the ambiguity #5061 refused to resolve
49+
consumer-side).
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: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
---
4+
5+
fix(plugin-audit): consume the engine's bound `ctx.previous` and record one normalised view on both sides of the diff (#6656)
6+
7+
`plugin-audit` used to fetch its own pre-image. `captureBefore`, registered on
8+
`beforeUpdate` / `beforeDelete`, issued a `ql.findOne` for the target row and
9+
stashed it on `ctx.__previous`, because `HookContext.previous` was "officially
10+
typed but not always populated by the engine itself". That is no longer true on
11+
any path this plugin registers for, so the read is retired and the writer reads
12+
the contract value.
13+
14+
**The read that goes away** (measured with a counting driver on the audited
15+
object, `driver.findOne` per write):
16+
17+
| write | before | after |
18+
|:--|--:|--:|
19+
| single-id `update()` | 2 | 1 |
20+
| single-id `delete()` | 2 | 1 |
21+
| predicate `update()`, 3 matched rows | 3 | 0 |
22+
| predicate `delete()`, 3 matched rows | 3 | 0 |
23+
24+
The predicate column is the larger half and was pure waste. #5574 binds
25+
`input.id` on every per-row *before* context, which defeated the handler's own
26+
`if (!id) return` bulk guard — so it read every matched row, and every result
27+
was discarded, because `__previous` landed on the per-row *before* context while
28+
the per-row *after* contexts (the ones the writer actually runs on) never saw
29+
it. The engine's own matched-row read is untouched and still serves both phases,
30+
so the ledger is unchanged.
31+
32+
**What the ledger records changes, and deliberately.** The two sides of an audit
33+
diff came from two different pipelines: `before` through the engine's read path
34+
(credentials masked, formulas hydrated, file references resolved) and `after`
35+
from the raw write result. That asymmetry — not the redundant read — is why a
36+
write that touched one field recorded phantom "changes" for every secret, file
37+
and formula field on the record. Retiring the read makes both sides
38+
same-source; the writer now also gives them one view, so the surface levels
39+
upward rather than down to raw store contents:
40+
41+
- **Credential fields are masked on both sides.** Single-id delete `old_value`
42+
still reads `••••••••` for a `secret` field — that face is byte-identical.
43+
Change detection still runs on the raw values, so rotating a secret is still
44+
recorded as a change; only the recorded values are masked.
45+
- **A pre-existing leak is closed.** The stored `secret:` ref was already
46+
reaching `sys_audit_log.new_value` on every create and update, and a
47+
`password` field — which ADR-0100 stores in cleartext at rest — was landing
48+
there **in plaintext**, in the audit ledger and in the `sys_activity` summary
49+
rendered in the record feed. Both now record the mask.
50+
- **Virtual (`formula`) fields leave the full snapshots.** `ctx.result` carries
51+
hydrated formulas (#5504) and the raw pre-image structurally cannot, so
52+
create `new_value` would have described a field delete `old_value` could
53+
never carry. Only genuinely virtual fields are dropped: `autonumber` and
54+
`summary` are stored columns present and equal on both sides, and they stay
55+
in the snapshot.
56+
57+
Two consequences worth naming, both narrowing single-id delete to what bulk
58+
delete already did: its `old_value` now records a file field's stored id rather
59+
than the resolved `{id, name, size, url}` object, and drops formula values. An
60+
object whose label field is a formula falls back to the record id in the
61+
`sys_activity` label on delete for the same reason.
62+
63+
No audit coverage is removed: the plugin keeps its `afterInsert` / `afterUpdate`
64+
/ `afterDelete` registrations, which is what holds the engine's pre-image demand
65+
gates open, and every one of them keeps the `excludeObjects` face from #5860.

0 commit comments

Comments
 (0)