Skip to content

Commit ee19e71

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-17784-tenant-schema-cache-ttl-unit
2 parents 0183e54 + 45b90b6 commit ee19e71

61 files changed

Lines changed: 5340 additions & 407 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: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
"@objectstack/plugin-approvals": minor
4+
---
5+
6+
An approval `decide()` that resumes a subflow CHILD now tells the caller when that resume bubbles into a PARENT run that stranded — instead of answering full success with nothing to distinguish it from a healthy composition (#15556; the #16472 family ruling, decision batch #76, option A).
7+
8+
**The composition.** A parent flow parks at a `subflow` node whose child hosts the `approval` node, so the approvals row names the CHILD run. The decision door resumes the child, the child completes, `bubbleToParent` resumes the parent, and the parent's own downstream node throws. The parent lands on the engine's `'stranded'` exit — it consumed its suspension and is now terminal, repairable only by an operator's `restoreConsumedSuspension` — and `bubbleToParent` already logged that at `error` (unchanged by this fix). What the caller was TOLD did not: `resumed: true`, no `resumeError`, and a `runId` naming the healthy child — identical to what a fully healthy composition answers.
9+
10+
```
11+
FROM service.decide(requestId, { decision: 'approve' }, ctx)
12+
-> { finalized: true, decision: 'approve', runId: '<child>', resumed: true }
13+
// identical to a healthy composition's answer — no caller can tell
14+
15+
TO service.decide(requestId, { decision: 'approve' }, ctx)
16+
-> { finalized: true, decision: 'approve', runId: '<child>', resumed: true,
17+
resumeError: "RESUME_FAILED: … its own flow run '<child>' resumed, but the " +
18+
"subflow parent above it — run '<parent>' — consumed its suspension " +
19+
"and is now stranded: <downstream error>",
20+
resumeFailure: { code: 'RESUME_FAILED', runId: '<parent>', status: 'stranded', repairable: true } }
21+
```
22+
23+
**Additive only — no migration.** `ApprovalDecisionResult.resumeFailure` was already declared (and pinned) in `@objectstack/spec` ahead of this card; this fix is the first producer that fills it. No existing field changes shape, no status code moves (the door still never throws for this shape — `AGENTS.md`'s "a failure handed to the caller" answer does not apply here, since before this fix no caller was told at all), and the door's `error` log line is untouched. A consumer that already ignores unknown fields sees no difference; a consumer that reads `resumeFailure` can now tell a bubbled parent strand from a clean resume without diffing `runId` against a durable run history.
24+
25+
**What did not move, on purpose.** `RESUME_IN_PROGRESS` / `STORE_UNAVAILABLE` bubble outcomes stay the functional degradation they always were (`warn`, unreported on `resumeFailure`) — the #16472 ruling is scoped to the one exit the engine calls `'stranded'`. The sibling `recall` door (`ApprovalRecallResult.resumeFailure`, #15970) is a separate card and is not touched here.
26+
27+
**New public surface — the reason for `minor` on both packages, not `patch`.** Getting the parent's strand from the engine to the approvals door without touching `packages/spec` or the wire-visible `AutomationResult` (which a raw REST `POST …/resume` also serves verbatim, so a field there would leak an undeclared key onto every subflow resume, not only an approvals-mediated one) needed a small new internal channel:
28+
29+
- `@objectstack/service-automation`: `AutomationEngine` gains a new public method, `takeSubflowParentStrand(childRunId: string): SubflowParentStrand | undefined` — read-once (deletes on read), populated only by `bubbleToParent`'s `'stranded'` exit. `SubflowParentStrand` is a new exported interface (`{ runId, repairable: true, error }`).
30+
- `@objectstack/plugin-approvals`: `ApprovalResumeSurface` (already exported from the package entry) gains a matching optional member, `takeSubflowParentStrand?(childRunId): { runId, repairable, error } | undefined`.
31+
32+
Both are additive and optional; nothing existing changes shape or behaviour. Neither reaches any wire payload — `AutomationResult`, the REST resume door's response, and every other published contract are byte-for-byte unchanged.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`CalendarConfigSchema` now declares **`allDayField`** — the fifth field binding on a calendar config, and the one key the rest of this package already published as a member while the schema refused it by name.
6+
7+
**The trap this closes.** The `object-calendar` door refuses a flat `allDayField` and prescribes, verbatim: *"Write this as a key of the `calendar` config object instead — `calendar: { startDateField, endDateField, titleField, colorField, allDayField }`."* That block's `calendar` prop `.describe()` publishes the same five-key shape, and it ships to `content/docs/references/ui/component.mdx`. An author who followed the prescription on a stored view was refused a **second** time, by a different schema with a different message — `Unrecognized key(s) on this calendar configuration: allDayField` — and neither message said the key was not a member at all, so the natural next move was to assume a typo and try more spellings.
8+
9+
**Why the schema was the wrong half, measured rather than assumed.** The key is honoured, not inert. At the objectui pin this repo builds against, `ListView`'s `collectViewFields` reads `calendar.allDayField` into the fetch projection and its calendar branch forwards the authored block onto the `object-calendar` node, where `getCalendarConfig` resolves it; objectui then made it load-bearing in the render itself. Trimming the prescription instead would have left a shipped capability with no protocol carrier — and the mirror that carries it today keeps `.passthrough()` explicitly so the key is not stripped, which means a later hardening there would silently drop it.
10+
11+
**What is authorable, and what still is not.**
12+
13+
```ts
14+
// accepted
15+
calendar: { startDateField: 'start_date', endDateField: 'end_date',
16+
titleField: 'subject', colorField: 'status', allDayField: 'is_all_day' }
17+
18+
// still refused — one key per concept, not a second authorable spelling
19+
{ type: 'object-calendar', allDayField: 'is_all_day' }
20+
```
21+
22+
`allDayField` **names a boolean field, not a value**: a record whose flag is true draws as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit it and the renderer's existing inference is untouched — an event with no end date draws as all-day — so every calendar that never authored the key renders exactly as before.
23+
24+
**The opening is one key wide.** `defaultView` stays refused on this config: it is the renderer's initial view mode, a UI preference rather than a field binding, and it already has its own declared home as an `object-calendar` component prop. Unknown keys are refused in the same shape as before, and `startDateField` is still required.
25+
26+
Purely additive: nothing that parsed before is refused now, and no key is renamed or removed.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: retire the `scheduled` cache-warmup strategy — the cron it selected left in this same major, and nothing ever warmed on a cadence (ADR-0049)
6+
7+
<!-- adr-0087: registered cache-warmup-scheduled-strategy-retired -->
8+
9+
**BREAKING** in the accept-set sense, landing in the launch window as `minor` (the
10+
lockstep convention: `major` is refused by `check-changeset-no-major`, and breaking-ness
11+
is carried by this banner plus the ADR-0087 disposition above).
12+
13+
`CacheWarmup.strategy` no longer accepts `'scheduled'`.
14+
15+
| | before | after |
16+
|:--|:--|:--|
17+
| accept set | `'eager' \| 'lazy' \| 'scheduled'` | `'eager' \| 'lazy'` |
18+
| describe | `… lazy (on first access), scheduled (cron)` | `… lazy (on first access)` |
19+
| a document writing it | parsed green | **refused**, with the prescription |
20+
21+
**The one-line fix:** write `strategy: 'eager'` (warm at startup) or `strategy: 'lazy'`
22+
(warm on first access). For a warmup on a **cadence**, declare a `job` — that is the one
23+
cron slot this platform evaluates:
24+
25+
```ts
26+
defineStack({
27+
jobs: [{ name: 'warm_config_cache', schedule: { expression: '0 * * * *' }, handler: 'warmConfigCache' }],
28+
});
29+
```
30+
31+
## Why
32+
33+
`cron-typed-positions-retired` (17.x → 18, #16320) deleted `CacheWarmup.schedule`, the
34+
cron key this enum member selected, and left the member standing on the reading that it is
35+
"a value, not a position the ruling names". That was a statement about that ruling's
36+
**scope**, not a finding that the value was sound. After the deletion the member declared a
37+
warmup cadence with **no key left to configure it and no engine that has ever run one**,
38+
while its own `.describe()` still promised `(cron)` — ADR-0049 declared-not-enforced, in
39+
the form Prime Directive 10 names outright: a capability advertised that the runtime does
40+
not deliver.
41+
42+
Nothing on the platform reads `CacheWarmupSchema`: outside its declaring file it resolves
43+
to the generated reference page's import line, the `declaration-map` / `export-origins`
44+
catalogues, the ADR-0058 D7 ledger comment and two of this package's own test files — zero
45+
runtime consumers, measured beside a lit control (`ConnectorSchema`, 46 files, same sweep).
46+
So **no runtime behaviour changes**: no warmup has ever run on a schedule, before or after.
47+
What changes is that the contract stops promising it.
48+
49+
## The retirement kit
50+
51+
- the member leaves `z.enum(['eager','lazy','scheduled'])` and the `.describe()` stops
52+
saying `(cron)` (`system/cache.zod.ts`)
53+
- the prescription hangs on **the enum's own `error` map, dispatched by `issue.input`** —
54+
the established route for an enum-VALUE retirement (`crypto.hash` on
55+
`HookBodyCapability`, `object.managedBy: 'system'`, `HotReloadConfig.stateStrategy`).
56+
There is no value-level analogue of `retiredKey()` and none is invented here. Only the
57+
value that **used to be legal** gets the "was removed" sentence; `strategy: 'sheduled'`
58+
keeps zod's own enum message, which already lists the legal values
59+
- an **ADR-0087 D3 semantic entry**, `cache-warmup-scheduled-strategy-retired` — a semantic
60+
entry rather than a D2 conversion because there is **no source to rewrite**: `CacheWarmup`
61+
is bound to no metadata type and embedded in no stack collection, so no authored document
62+
and no stored row has ever carried this value, and `os migrate meta` has nothing to list.
63+
That is also why the prescription carries **no `os migrate meta` sentence** — it would
64+
promise a listing the tool cannot produce, which is the very defect this card is about
65+
- **nothing in `RETIRED_KEYS_BY_MAJOR`** — no authorable *key* changed — and **no
66+
`retiredKey()` tombstone**, which tombstones keys, not values
67+
- pin tests (`system/cache.test.ts`): the refusal and its prescription, a **lit control**
68+
that a typo is *not* told it "was removed", and that the surviving members and the
69+
`'lazy'` default still parse. `cron-typed-positions-retirement.test.ts`'s warmup fixture
70+
moves to `'eager'`, since a fixture must be well-formed under the current schema
71+
72+
## ⚠️ The four surface ratchets are byte-identical across this change, and that is correct
73+
74+
An enum-VALUE narrowing moves no position, no exported name and no expression-typed slot:
75+
`authorable-surface/` keys on **positions** (`system/CacheWarmup:strategy` stays — the key
76+
is untouched), the ADR-0058 D7 ledger on **expression-typed slots**, and `api-surface/` /
77+
`json-schema.manifest/` on **names**. None of them reads a def's *value set*, so none of
78+
them can fail on this change — the `crypto.hash` precedent measured exactly this. The pin
79+
tests above are therefore not a formality: they are the only instrument this retirement
80+
has, and a green CI run on its own says nothing about whether the value is gone.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/runtime': patch
5+
'@objectstack/verify': patch
6+
---
7+
8+
`@objectstack/spec/kernel` exports `SEED_WRITE_EXECUTION_CONTEXT`, the one spelling of the seed-write posture every seeder now reads
9+
10+
The execution context a seed write must use — `isSystem`, `skipTriggers`,
11+
`seedReplay` — had **no exported form**, so every seeder held a private copy of
12+
it and nothing held the copies equal. There were three on `main`:
13+
`SeedLoaderService.SEED_OPTIONS` (`@objectstack/metadata-protocol`),
14+
`SEED_WRITE_OPTIONS` (`@objectstack/runtime`'s `AppPlugin`, whose own docblock
15+
already recorded that it "mirrors" the first) and `SEED_CONTEXT`
16+
(`@objectstack/verify`'s fixture writer, which spelled it a third time
17+
specifically because the runtime kept its copy module-private).
18+
19+
**Why a shared constant rather than three accurate copies.** `skipTriggers` is
20+
what suppresses "on create" automation for seed rows, and `isSystem` alone does
21+
**not** suppress dispatch. A seed path that lost that flag once seeded with
22+
automation live while the main path had it suppressed — a self-trigger loop that
23+
wedged first boot (#3760). A constant whose divergence re-opens a boot-wedging
24+
defect is a kernel semantic, not a local detail.
25+
26+
**What is exported, and what deliberately is not.** The **inner**
27+
`ExecutionContext` value, and nothing wrapped around it:
28+
29+
```ts
30+
import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel';
31+
32+
await ql.insert(object, rows, { context: SEED_WRITE_EXECUTION_CONTEXT });
33+
```
34+
35+
The `{ context: … }` options bag stays at the call site. It is what all three
36+
sites ultimately hand to `insert`, but it is an options envelope rather than the
37+
posture: its type differs per engine method, so freezing one bag onto the
38+
protocol surface would serve `insert` and no other operation, and it is
39+
precisely the convenience bundle this export is not.
40+
41+
⛔ **No behaviour change.** The value is byte-identical to all three previous
42+
copies, the three flags keep their existing meanings, and no seed path changes
43+
what it writes or how. The three former copies now read this export, so the two
44+
option bags are `{ context: SEED_WRITE_EXECUTION_CONTEXT }` and the `verify`
45+
context is the export itself.
46+
47+
**Additive, so `minor` on `@objectstack/spec`**: one new name on the existing
48+
`./kernel` entry point, no existing export removed, renamed or narrowed. The
49+
three consumers take `patch` — their published `dist` changes (an import edge,
50+
and the constant now resolves through `@objectstack/spec/kernel`) while their
51+
own public surfaces do not move.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): `permission-retired-lifecycle-residue` — the retired `allowRestore` / `allowPurge` bits are now named at the authoring door (#17425)
6+
7+
`ObjectPermissionSchema` accepts `allowRestore: false` / `allowPurge: false` as inert residue and strips them silently. That tolerance is #12840's class ruling and is unchanged here: the accept set does not move, no schema is touched, and every other value keeps the tombstone's loud refusal.
8+
9+
The silence is deliberate — every artifact the published 17.x toolchain built has the retired default materialized in every permission entry, and a per-occurrence notice would be a storm. But `acceptRetiredDefaultResidue`'s own docblock names the channels that stay loud for authored sources — tsc `never`, `os migrate meta`, the ADR-0087 D2 conversion — and against a non-TypeScript author that list is one entry short. `tsc never` is a TypeScript channel. The conversion and `os migrate meta` are the same channel twice, and `permission-allow-restore-purge-removed` is declared `retiredFromLoadPath`, so it never fires while a stack loads. An author who writes the key in a JSON or YAML source and does not run the migration gets a clean parse and no signal at all — which is what a tombstone exists to prevent.
10+
11+
`os validate`, `os build` and `os lint` now emit one advisory `warning` per carrying entry, on the raw pre-parse stack where the key is still present and still attributable to a line somebody wrote. The hint is the retirement's own prescription, read from the tombstone's published description rather than retyped, so it cannot drift from the parse-time wording the same author sees through the other door.
12+
13+
It fires on the captured residue value and on nothing else: `true`, `"false"`, `0` and `null` are already refused at the parse with the prescription attached, and the surviving enforced lifecycle bit `allowTransfer: false` is not residue and is never named.
14+
15+
New published exports on `@objectstack/lint`: `validateRetiredPermissionResidue`, `PERMISSION_RETIRED_LIFECYCLE_RESIDUE` and the `RetiredPermissionResidueFinding` type. Nothing is removed and no existing finding changes shape or severity.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
`environments.update`'s JSDoc no longer advertises writes the control plane refuses, and `environments.updateVisibility` carries a current-state note.
6+
7+
The `update` comment listed `display_name, plan, status, is_default, metadata` as the updatable set, and the namespace route table listed `plan` and `status` too. `plan` and `status` are read-only columns on the control plane; the generic `PATCH /api/v1/cloud/environments/:id` route answers an unknown or read-only key with a **400** rather than dropping it, so the comment was actively teaching a call that fails. The same prose implied `visibility` was writable while it is server-owned.
8+
9+
Three prose sites move, all in `packages/client/src/index.ts`:
10+
11+
- **The namespace route-table docblock.** The PATCH accept-set now reads `display_name, is_default, metadata`, with the redirects stated: plan changes go through the billing routes, status changes through the lifecycle actions (archive / restore / suspend / resume), and `visibility` is server-owned.
12+
- **`update`'s JSDoc.** The same accept-set with per-field detail, and — the sentence that matters most to a caller — that an unknown or read-only key is answered with a 400 and is **not** dropped silently. Silent-drop is the assumption a caller reasonably makes today, and it is the wrong one.
13+
- **`updateVisibility`'s JSDoc.** A note that the call is refused today, so the paragraph describing what `public` does describes a capability that does not exist yet. The 2026-09-12 maintainer ruling keeps `visibility` server-owned and forced to `private` until the public-listing feature ships, at which point it gets its own endpoint rather than this generic update.
14+
15+
⛔ **No signature, type or runtime byte moves.** `patch` stays `Record<string, unknown>` and `updateVisibility`'s signature and body are byte-for-byte unchanged (verified by hash, before and after). Narrowing a published accept-set is as much a breaking change as widening one, and retiring, re-signing or throwing from a published SDK method is a maintainer ruling — neither is a doc fix's to make. What reaches consumers is the hover text in `dist/index.d.ts`.
16+
17+
⚠️ The 400 is an **inherited reading**, not one measured from this repo: `/api/v1/cloud/*` is served by `objectstack-ai/cloud`, which is not readable from here, so no gate here can check it. The comments say so at the point of the claim rather than leaving a later reader to try.

0 commit comments

Comments
 (0)