Skip to content

Commit f2ba53b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/d1-d2-strict-default-flip-vbly5z
# Conflicts: # content/docs/releases/v17.mdx
2 parents df3c392 + 86a71d1 commit f2ba53b

65 files changed

Lines changed: 2792 additions & 252 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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/service-storage": minor
3+
---
4+
5+
feat(storage): released field files enter collection on deployments that verified their file migration — ADR-0104 D3 wave 2 PR-5b (#3459)
6+
7+
The gated, final step of the file-as-reference sequence. On a deployment whose
8+
`adr-0104-file-references` flag is verified (`os migrate files-to-references
9+
--apply`, #3617), releasing a field file's ownership — clearing the field, or
10+
deleting the owning record — now also tombstones the file
11+
(`status='deleted'` + `deleted_at`), which starts the `sys_file` lifecycle's
12+
declared 30-day grace window and, at its end, hands the row to the reap sweep.
13+
Re-referencing the id inside the window revives it, exactly like re-attaching
14+
an attachment.
15+
16+
**The two halves ship together, deliberately.** The same change extends the
17+
reap guard's sweep-time re-verify beyond `sys_attachment` join rows to the
18+
ownership columns: a tombstoned file whose `ref_*` columns name a current
19+
owner (re-claimed in the window, or a release/claim race) is un-tombstoned and
20+
vetoed. Tombstoning released files without that re-verify would have turned
21+
every release into a *guaranteed* byte delete — the guard's old check consults
22+
a table that is always empty for field files. This pairing was the standing
23+
hard constraint on #3459, locked by regression tests on both halves.
24+
25+
**Nothing changes for a deployment that has not migrated.** Release keeps
26+
clearing the ownership columns only, and released files are retained forever.
27+
Every way of not knowing — no flag row, an unreadable table, an engine that
28+
cannot be asked — reads as "not verified": the gate fails closed, toward
29+
retention. And the guard re-reads the flag *fresh* at sweep time (not the
30+
release path's memoized read), so a later failing migration run — a database
31+
that has drifted — closes the gate for already-written tombstones too, without
32+
a restart. Attachments-scope collection is unchanged and needs no flag.
33+
34+
The irreversible moment is therefore per deployment: day 30 after *that*
35+
deployment verified its migration and released a file — never the upgrade
36+
itself.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
feat(objectql): `engine.isFileReferencesMigrationVerified()` is public — one memoized flag read for both in-process consumers (#3459 PR-5b)
6+
7+
The memoized per-deployment read of the `adr-0104-file-references` migration
8+
flag was private to the engine's media value-shape enforcement. The storage
9+
service's release path now asks the same question — may a released field file
10+
be tombstoned? — so the method is public and the release hooks reach it as an
11+
optional duck-typed member (an older engine or a test fake reads as "not
12+
verified", failing closed). One read, one invalidation
13+
(`invalidateDataMigrationFlags()`), no way for the two consumers to see
14+
different answers.

.changeset/adr-0116-followups.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
"@objectstack/plugin-webhooks": patch
4+
---
5+
6+
fix(plugin-auth,plugin-webhooks): retire a dead degrade branch and an implicit transitive dependency (ADR-0116 follow-ups, #4187)
7+
8+
Two concrete findings from the ADR-0116 consumer-side audit, plus the
9+
authoring rule that would have prevented both.
10+
11+
**`plugin-auth` claimed a fallback it did not have.** `init()` ran
12+
`const dataEngine = ctx.getService('data'); if (!dataEngine) { warn('No data
13+
engine service found - auth will use in-memory storage') }`. That branch could
14+
never execute: `getService` **throws** for an unregistered service rather than
15+
returning `undefined`, and this plugin declares a hard dependency on ObjectQL
16+
(which registers `data` unconditionally), so a kernel without the engine fails
17+
even earlier with `Dependency … not found`. The branch is removed and the real
18+
contract is declared — `requiresServices: ['data', 'manifest']` — which also
19+
replaces a trailing `// manifest service required` comment with the
20+
machine-checked form of the same claim. `AuthManager` keeps its own optional
21+
`dataEngine` guards: it is usable outside the plugin.
22+
23+
**`plugin-webhook-outbox` was protected only transitively.** It resolves
24+
`manifest` in `init()` with no fallback while depending on
25+
`com.objectstack.service.messaging`, which in turn depends on ObjectQL, the
26+
actual provider. That works today and would have broken silently the day
27+
messaging stopped depending on the engine — surfacing as a crash inside an
28+
unrelated plugin's init. It now declares `requiresServices: ['manifest']`
29+
directly.
30+
31+
Neither change alters ordering or boot outcomes on any current composition:
32+
both plugins were already ordered correctly. What changes is what a broken
33+
composition *says*, and that the guarantees are now checked rather than
34+
inherited.
35+
36+
Docs: `content/docs/plugins/anatomy.mdx` gains the three ADR-0116 fields and
37+
the decision rule for resolving a service inside `init()` (hard dependency vs
38+
`optionalDependencies` + `requiresServices`), including the two traps behind
39+
these fixes — don't rely on a transitive provider, and don't write an
40+
`if (!svc)` fallback after a bare `getService`. The api-registry example
41+
declares the contract on all seven of its plugins instead of relying on
42+
`kernel.use()` order.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': patch
4+
'@objectstack/metadata-protocol': patch
5+
---
6+
7+
Discovery's "install this to enable" now names a package that exists (#4093 follow-up).
8+
9+
Discovery tells a consumer two things about an absent capability: that it is absent, and what to do about it. The first has been carefully honest since #2462/#4000. The second was invented from the slot name.
10+
11+
The dispatcher templated `Install a ${slot} plugin to enable` across twelve slots, and `metadata-protocol` carried a hand-written table in which **ten of fifteen entries named a package that does not exist**`plugin-redis`, `plugin-bullmq`, `job-scheduler`, `plugin-notifications`, `plugin-storage`, `plugin-automation`, `ui-plugin`, plus `plugin-ai`, `plugin-search` and `plugin-workflow` for slots nothing implements at all. That value is also surfaced as discovery's `provider`.
12+
13+
A remedy naming a package that cannot be installed is a dead end handed to someone at the exact moment they are trying to fix their stack — and an agent reading discovery cannot tell it apart from a package it should install. It is the same `declared ≠ enforced` failure this lineage has been closing, one level over: not "does the capability exist" but "is the fix real".
14+
15+
`CORE_SERVICE_PROVIDER` and `serviceUnavailableMessage()` in `@objectstack/spec/system` are now the one place that sentence is written, and both discovery builders read them, so the two hosts cannot tell a consumer to install different things (the drift #4089 and #4130 closed for the `metadata` and `data` entries). Entries were verified against what actually calls `registerService` for each slot rather than against name similarity — which is how `notification` turned out to be filled by `@objectstack/service-messaging`, the one slot whose package shares no word with its name.
16+
17+
Four slots — `ai`, `search`, `workflow`, `graphql` — have no implementation anywhere, so they now say so instead of naming a plausible package. `ui` keeps the fuller sentence it got in #4146 (`/ui` is served by the `protocol` service; nothing registers the `ui` slot), and that sentence now reaches both builders instead of one.
18+
19+
`scripts/check-service-providers.mjs` (wired into the lint workflow as `check:service-providers`) fails CI when a named package is not a real workspace package, or when a `CoreServiceName` slot has no entry — so a rename or a deletion cannot leave a stale instruction behind.
20+
21+
FROM → TO: `services.<slot>.message` and `services.<slot>.provider` change text for most unavailable slots. Anything matching on the old `Install a <slot> plugin to enable` wording should match on `status: 'unavailable'` instead — the status field is the contract; the message is prose for humans and agents.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": patch
4+
"@objectstack/metadata-protocol": patch
5+
"@objectstack/driver-mongodb": patch
6+
---
7+
8+
fix(spec): five exported symbols resolved to `any` — type the recursive schemas and gate it in CI (#4171)
9+
10+
A recursive Zod schema needs an explicit annotation to break its circular
11+
inference, and five of them took the cheapest one available:
12+
13+
```ts
14+
export const NavigationItemSchema: z.ZodType<any> = z.lazy(() => …);
15+
export type NavigationItem = z.infer<typeof NavigationItemSchema>; // → any
16+
```
17+
18+
It compiles, it validates correctly at runtime, and it silently throws the type
19+
away. `NavigationItem`, `FormField`, `JoinNode` and `NormalizedFilter` were all
20+
`any` on the published surface, plus `FieldNodeSchema` — which had no exported
21+
type alias yet, so `z.infer<typeof FieldNodeSchema>` was `any` and
22+
`QueryAST['fields']` with it.
23+
24+
That is worse than a missing export. #4115 tells every consumer that a local
25+
declaration under a spec export's name must be replaced by a binding to the
26+
spec — and for these, obeying it **replaced a precise type with `any`**.
27+
objectui's `NavigationItem` is a 118-line documented interface (`recordId`
28+
template variables, `requiresObject` / `requiresService` capability gates,
29+
`filters` precedence); every key of it exists in the spec's version, so by every
30+
available signal it read as a redundant fork safe to delete. Deleting it swapped
31+
a fully-typed interface for `any`, with no compile error anywhere to say so.
32+
33+
It is hard to catch by inspection because `any` is mutually assignable with
34+
everything, so the natural "are these the same type?" check answers *yes* in both
35+
directions and recommends precisely the wrong action. Same failure family as
36+
#4075's `[key: string]: any` on `ActionDef`: a type that agrees with everything
37+
reads as agreement.
38+
39+
**Now annotated with the real type**, using the pattern `QueryAST` already
40+
follows in `data/query.zod.ts` — infer the non-recursive part, tie the recursive
41+
knot in the type, so the keys stay derived from the schema instead of being
42+
hand-maintained beside it:
43+
44+
```ts
45+
const BaseXSchema = z.object({ …every non-recursive key });
46+
export type X = z.infer<typeof BaseXSchema> & { children?: X[] };
47+
export const XSchema: z.ZodType<X> = z.lazy(() => BaseXSchema.extend({
48+
children: z.array(XSchema).optional(),
49+
}));
50+
```
51+
52+
`z.infer` now resolves to the type it should always have been: `NavigationItem`
53+
is the nine-branch discriminated union, `FormField` the 30-key form-field
54+
contract (with `visibleOn` absent by construction — ADR-0089 D2 folds it into
55+
`visibleWhen` at the boundary), `JoinNode` and the newly exported `FieldNode`
56+
the query AST nodes, `NormalizedFilter` the normalized filter AST. Runtime
57+
validation is unchanged: every schema parses exactly what it parsed before.
58+
59+
**What the types immediately caught**, none of it visible while they were `any`:
60+
61+
- `account.app.ts` set `defaultOpen` on three nav groups — a key the spec has
62+
never declared. It worked only because objectui's `NavigationRenderer` still
63+
falls back to that legacy alias. Fixed at the producer per Prime Directive
64+
#12: the canonical key is `expanded`.
65+
- The MongoDB driver built its projection with `projection[field] = 1` over
66+
`query.fields`, so a relationship `FieldNode` would have keyed the projection
67+
on `"[object Object]"`. It now reads the node's field name.
68+
- `setup.app.ts`, `studio.app.ts` and `setup-nav.contributions.ts` are annotated
69+
with the PARSED `App` / `NavigationContribution` types but omitted
70+
`.default()`ed keys (`expanded`, `target`), as did the form fields
71+
`metadata-protocol` synthesizes for `getUiView` (`span`). Each now states the
72+
default it was relying on, matching what the surrounding literals already do
73+
for `active` / `isDefault` / `collapsible` / `collapsed` / `columns`.
74+
75+
**Gated, not just fixed** (`check:exported-any`, wired into the required
76+
`TypeScript Type Check` job). `api-surface.json` records that an export *exists*
77+
and never what it *resolves to*, which is how these survived a whole major with
78+
every gate green. The new scan reads the built `.d.ts` a consumer's import
79+
actually resolves to and fails on any exported type that resolves to `any` — or
80+
any exported schema whose output is `any`, the root cause, and the only reason
81+
`FieldNodeSchema` was visible at all. Its `KNOWN_ANY` ledger is shrink-only and
82+
currently empty. It self-tests against the real zod first, so if the internals it
83+
reads are ever renamed the gate fails loudly instead of quietly passing
84+
everything forever.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/cli': patch
4+
---
5+
6+
**`config.storage` is not a stack key, and an undeclared top-level key now says
7+
so instead of vanishing (#4167).**
8+
9+
`os serve` read `config.storage` and forwarded it to `StorageServicePlugin`.
10+
It could almost never arrive: `ObjectStackDefinitionSchema` does not declare
11+
`storage`, and is not `.strict()`, so `defineStack` — which every documented
12+
authoring path and every compiled artifact goes through — strips the key before
13+
`serve` runs. The one combination that reached the branch (a bare-object config
14+
on the config-boot path) then carried the `driver`/`root` spelling the plugin
15+
does not read either, so it did nothing there too.
16+
17+
The result was one authoring key that worked on a single unreachable-in-practice
18+
path and disappeared silently everywhere else. A host writing
19+
`storage: { driver: 's3', … }` believed it had configured S3 and got local disk.
20+
21+
- **`serve` no longer reads it.** `resolveStorageCapabilityArg` takes only the
22+
env root; the production warning stops naming `config.storage` and names the
23+
two channels that work — `OS_STORAGE_*` and Setup → Settings, the latter being
24+
the one with proper credential handling.
25+
- **The undeclared-key lint now covers the stack's own top-level keys.** New
26+
`lintUnknownStackKeys(rawStack, stackSchema)`, wired into `defineStack`,
27+
`os validate` and `os compile` beside the existing walker. `storage` gets a
28+
prescriptive entry naming both channels and why a stack definition is the
29+
wrong home for a credential — it would commit it to git and to any published
30+
artifact. An ordinary misspelling still gets the edit-distance suggestion
31+
(`datasource``datasources`).
32+
- **`os migrate files-to-references` shares the resolver.** It built
33+
`{ driver: 'local', root }` — the same dead keys — so its adapter used
34+
`./storage` while the server writes under `.objectstack/data/uploads` since
35+
#4096. That command reconciles what records claim against what storage holds,
36+
so a disagreeing root reconciled against the wrong tree.
37+
38+
**`onEnable` is exempt, and the exemption has one owner.** `onEnable` is a
39+
function, so `ObjectStackDefinitionSchema` cannot declare it and
40+
`dist/objectstack.json` cannot carry it — but it is not lost: `AppPlugin` calls
41+
it off the authored bundle, and the artifact-boot path grafts it back (#4095).
42+
"Not declared" and "dropped at load" are different claims, and this is the
43+
surface where they come apart. New `STACK_RUNTIME_MEMBERS` in `@objectstack/spec`
44+
names the members the runtime honours off the bundle; the lint treats them as
45+
declared, and the CLI's `GRAFTABLE_RUNTIME_MEMBERS` is now **derived** from it
46+
rather than restating it, so the list that decides what gets grafted and the
47+
list that decides what the lint stays quiet about cannot drift. `onDisable` is
48+
deliberately not on it — nothing calls it, so a value written there really does
49+
go nowhere and the lint should say so.
50+
51+
Additive: `lintUnknownAuthoringKeys` keeps its signature. The new pass is a
52+
separate export rather than a fold into that walker for two reasons. The walker
53+
iterates metadata COLLECTIONS, so a stack whose only mistake is at the envelope
54+
level — no objects, no pages, nothing to iterate — walks clean; and the stack
55+
schema has to be INJECTED, because `stack.zod.ts` imports the lint module and
56+
importing back would close a cycle. A separate export keeps that requirement
57+
visible: a call site either asks for the coverage or does not, and its absence
58+
shows up in a diff. An optional parameter would be the same silent-loss shape
59+
this rule family exists to report. It follows the walker's posture rule — only a
60+
schema that STRIPS unknown keys is linted, so if the stack schema ever graduates
61+
to `.strict()` the parse takes over and this goes quiet.
62+
63+
Verified end to end: authoring `storage:` through `defineStack` warns at load,
64+
and `os compile` reports it for configs that skip `defineStack`.
65+
66+
Nothing is being taken away that worked. `storage` was never in the schema, is
67+
not documented anywhere, and has no consumer in `objectstack-ai/cloud` (checked).
68+
Whether the platform should eventually grow a real in-stack storage declaration
69+
is a separate question — if so it should follow `datasources`, which solves
70+
credentials by referencing `sys_secret` rather than inlining them, and that
71+
deserves an ADR rather than a resurrected undeclared key.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
---
3+
4+
Test-only: extend the ADR-0053 D-A3 storage-form axis to `Field.time` and the
5+
wasm driver (#4191) — legacy-storage conformance sweeps in `driver-sql` and
6+
`driver-sqlite-wasm`, plus doc-comment formalization of the axis in
7+
`spec/data/temporal-conformance.ts`. Releases nothing.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
Hooks and datasources reject unknown keys (#4001 data step).
6+
7+
Closes the last two entries in the strictness ledger that still carried a
8+
provisional classification. Both were confirmed authorable the same way: they sit
9+
in `BUILTIN_METADATA_TYPE_SCHEMAS`, so one shape backs `defineStack()` parsing,
10+
`/api/v1/meta/types/:type`, and the Studio form.
11+
12+
Now strict:
13+
14+
- `HookSchema` + its `retryPolicy`, and both hook-body branches
15+
(`ExpressionBodySchema`, `ScriptBodySchema`). A misspelt `capabilities`
16+
stripped to the empty default and the sandbox threw at invocation time instead
17+
of at parse; a misspelt `timeoutMs`/`memoryMb` silently downgraded the body to
18+
the enclosing hook's limits.
19+
- `DatasourceSchema` + `pool` / `healthCheck` / `ssl` / `retryPolicy`,
20+
`ExternalDatasourceSettingsSchema` + its `validation` block,
21+
`DatasourceCapabilities`, and `DriverDefinitionSchema`.
22+
23+
Deliberately still tolerant:
24+
25+
- `HookContextSchema` and its `session` / `provenance` / `user` blocks — the
26+
runtime shape the engine hands a handler. Strictness there would turn an
27+
engine-internal enrichment (as `provenance` was in #3712) into a breaking
28+
change for anyone parsing a context they were given.
29+
- `datasource.config` and `readReplicas` — per-driver by construction; the
30+
driver's own `configSchema` validates them.
31+
32+
Errors are self-fixing: connection keys written one level too high (`host`,
33+
`port`, `filename`, `url`, …) are prescribed into `config`; a top-level
34+
`password` is pointed at `external.credentialsRef` rather than merely relocated;
35+
and the two near-miss spellings that cross between shapes carry aliases
36+
(hook-level `timeout` vs body-level `timeoutMs`; hook `retryPolicy.backoffMs` vs
37+
datasource `retryPolicy.baseDelayMs`).
38+
39+
Note for anyone reading the earlier steps: strictness does not change the
40+
published JSON Schema. `build-schemas.ts` converts with `io: 'output'`, where zod
41+
emits `additionalProperties: false` for `.strip()` objects too — verified by
42+
regenerating both ways (`Datasource.json` is byte-identical). The JSON Schema was
43+
already advertising `additionalProperties: false` while the parse silently
44+
dropped keys; this aligns the parse with the published contract.

0 commit comments

Comments
 (0)