Skip to content

Commit 668b48f

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16337-finddata-canonical-queryast
# Conflicts: # content/docs/permissions/system-context.mdx
2 parents afbf7a4 + 8341ed2 commit 668b48f

82 files changed

Lines changed: 5525 additions & 1113 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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/client": minor
5+
---
6+
7+
The automation resume route's `400 FLOW_FAILED` now says whether the run is stranded.
8+
9+
`POST /api/v1/automation/:name/runs/:runId/resume` answers a run that consumed its pause and then failed with `400 FLOW_FAILED`, and until now its `error.details` carried the run's two artefacts only (`errorMessage`, `summary`). The engine's own verdict was dropped at the door: `AutomationResult.status: 'stranded'` — a run that is terminally failed *but* repairable by an explicit operator verb, because the pause a durable decision was waiting on is gone with the failure — reached the wire as the same `400` a plain terminal failure does, so an HTTP-only caller could not tell "beyond reach" from "repair waiting".
10+
11+
- **`@objectstack/spec`** declares `ResumeFailureDetailsSchema` (`@objectstack/spec/api`): `{ runId, status?: 'failed' | 'stranded', repairable }` — the machine-readable shape of a resume failure told to the caller, declared once so every carrier of the family ruling spells the same members.
12+
- **`@objectstack/runtime`**: the resume door's `400 FLOW_FAILED` details now carry that structure beside `errorMessage` / `summary`. `runId` is the run the resume was addressed to; `status` is the engine's own stamp, forwarded verbatim when it set one and never synthesised (the subflow-child-failed exit stamps none today); `repairable` is `status === 'stranded'` and is **always present on this arm** — present-and-false on a plain terminal failure, deliberately, so an absent member reads as an older server rather than as "not repairable". The code stays `FLOW_FAILED` (no `FLOW_STRANDED` sibling is minted), so a client that treats it as terminal keeps working and one that wants to offer a repair branches on `details.repairable`, never on the message text. The trigger door and `/actions` are unchanged: they never resume, so the member is absent there and absent means "not a resume".
13+
- **`@objectstack/client`**: `automation.resume` documents the new members.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare the two operator run-lifecycle verbs on `IAutomationService``cancelRun` and `restoreConsumedSuspension` (#16495, the contract half of #13953)
6+
7+
`IAutomationService` (`contracts/automation-service.ts`) gains two OPTIONAL
8+
members, typed as the engine already implements them rather than as the
9+
ruling's `verb(runId)` shorthand, so a door calling through the contract can
10+
say who asked and why:
11+
12+
- `cancelRun?(runId: string, reason?: string): Promise<boolean>` — end a
13+
suspended run (ADR-0044's run-cancel primitive): `true` only when this call
14+
consumed a suspension, `false` when none exists under the id (idempotent
15+
success — and the answer an unreadable store lands on too, which the
16+
implementation reports at `error`).
17+
- `restoreConsumedSuspension?(runId: string, options?: { requestedBy?: string; reason?: string })`
18+
answering `{ restored: boolean; runId: string; refusal?: string; reason: string }`
19+
— the operator exit from a run a resume left terminally unresumable
20+
(`AutomationResult.status: 'stranded'`, #13909 / #13937): puts the consumed
21+
suspension back verbatim, replays no signal, undoes nothing, never resumes,
22+
never throws.
23+
24+
Both docblocks carry the #13953 ruling's persistent-face statement (maintainer
25+
2026-09-05, decision batch #42): "listing and acting go through
26+
`sys_automation_run` (the persistent face), never engine memory" — and its
27+
permission posture: platform-operator verbs gated on the existing
28+
`platform_admin` position, no new permission type, no per-run ownership.
29+
30+
Additive. Both members are optional, so every existing implementation —
31+
including the `{ execute, listFlows }` minimum the contract's own test pins —
32+
still conforms, and the one non-test implementor (`AutomationEngine` in
33+
`@objectstack/service-automation`) already satisfies both under `implements`.
34+
The result of `restoreConsumedSuspension` is a deliberately NARROWER
35+
structural shape than the engine's `SuspensionRestoreResult`: the engine's
36+
eight-member refusal vocabulary stays with the engine, so `refusal` is typed
37+
`string` on the contract (route (i); a second consumer that needs the
38+
vocabulary is a spec card). No REST route, CLI command, lister or engine
39+
behaviour moves in this change — #13953's services half owns the doors. A
40+
service that does not declare a verb has no operator door for it, and a door
41+
must probe for presence and refuse fail-closed when it is absent.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
`validateStackExpressions` no longer throws on a non-record entry in an object's `fields:` list.
6+
7+
An empty item in a YAML `fields:` list deserialises to `null`, and `buildFieldIndex` cast each member of the list inline (`fields.map(f => (f as AnyRec).name)`) before the `.filter` two calls later could drop it. `Array.isArray` proves the LIST, never its MEMBERS, so linting such a stack failed with `TypeError: Cannot read properties of null (reading 'name')` out of the whole rule instead of reporting anything about the file.
8+
9+
The list is now read through `recordsOf` — the one place that coercion is decided — which drops a non-record member of the array shape whole and in **silence**: it carries no author-written name, so there is nothing to report about it. That matches what the two sibling field readers in the same module (`buildFieldTypeIndex`, `fieldEntries`) already did with the same member, so the three readers now agree. The readable siblings of the junk member are still indexed, so unknown-field findings on that object continue to be reported.
10+
11+
The map shape (`fields: { amount: { … } }`) is unchanged: there the author's key is the field name, which is what this index needs.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`translatePage` now reads a region-level `page:header` by **page name only**. The id route
6+
(`pages.<page>.components.<headerId>.*`) is no longer read for that component, even when it carries an `id`.
7+
8+
**Behaviour change, stated plainly:** a bundle that overrode a region-level header's title through
9+
`pages.<page>.components.<headerId>.title` now falls back to `pages.<page>.title` (which itself falls back to
10+
`pages.<page>.label`). The components key still parses — nothing is removed from `TranslationBundleSchema` — it is
11+
simply no longer the address for this one component.
12+
13+
```
14+
FROM pages.<page>.components.<headerId>.title # region-level page:header — no longer read
15+
TO pages.<page>.title # …and .subtitle for the subtitle
16+
```
17+
18+
Fix in one line: move the string from the components entry up to the page's own `title` key, and delete the
19+
components entry for that header id. `os i18n extract` has always offered exactly the `TO` key, so a bundle
20+
generated or checked by the CLI already writes it.
21+
22+
**Blast radius, as measured on the card (inherited, not re-measured here):** HotCRM found **zero** such overrides —
23+
all five of its region-level headers carry ids and none writes the components key.
24+
25+
**Why.** Both sides were deliberate and they disagreed. The extractor skips a region-level `page:header` on purpose
26+
(its copy is offered under `pages.<page>.title` / `.subtitle`, and emitting it twice would put one string under two
27+
keys); the resolver read the id route on purpose (the more specific route wins). Together they produced the exact
28+
failure `walkAddressedPageComponents` was extracted to prevent — the resolver reading an id the extractor omits — so
29+
the key an author reached for won silently while the key the tooling reported as translated lost. The maintainer
30+
ruled (2026-09-06, decision batch #58, verbatim 「同意」) that the page-name route is canonical: one component, one
31+
address. `title` and `subtitle` now follow the same rule, closing the asymmetry where `title` had two addresses and
32+
`subtitle` — never in `PAGE_COMPONENT_COPY_KEYS` — had one.
33+
34+
Unchanged: a `page:header` **nested** inside a container is reached by the id route only, as it always has been; and
35+
a region-level header's id still claims its bundle entry and still blocks a nested namesake, which is what the
36+
extractor does too.
37+
38+
Not `major`: nothing an author can write is removed or renamed. `pages.<page>.components.<id>` remains a declared,
39+
parsing, resolving address for every other component — including a nested `page:header` — so there is no key to
40+
tombstone and no ADR-0087 conversion to register. Recorded here so the choice is checkable rather than assumed.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": minor
4+
---
5+
6+
`PluginSchema` now REQUIRES `staticPath` and `slug` when `type` is `'ui'`, and core's `Plugin` interface inherits every `PluginSchema` key from `PluginDefinition` instead of restating two of them.
7+
8+
**BREAKING** accept-set narrowing on a published schema, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). `packages/spec/src/kernel/plugin.zod.ts` described `staticPath` and `slug` as *"Required for type=\"ui\""* while declaring both `.optional()`, with nothing behind the prose; since `kernel.use()` runs the schema on the boot path (#16049), that was a promise the runtime visibly did not keep. This is the spec half of #16049, split by director ruling (decision batch #58, 2026-09-06).
9+
10+
**Exactly what is newly refused.** A plugin object with `type: 'ui'` that omits `staticPath`, omits `slug`, or spells either as `undefined`. Nothing else: every other declared type (`standard`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`), and a plugin declaring no `type` at all, still parses with neither key. A PRESENT value is judged exactly as before — `slug` keeps its `/^[a-z0-9-_]+$/` regex, `staticPath` stays any string, and the empty string is not refused by this change.
11+
12+
**What a refusal looks like.** One zod issue per missing key, `path` naming the key, the new stable code `PLUGIN_UI_REQUIRED_KEY_MISSING` (exported from `@objectstack/spec/kernel`) at the head of the issue `message` and on the issue's `params.code`. At `kernel.use()` it rides the existing `PLUGIN_CONTRACT_VIOLATION` envelope unchanged, because the loader surfaces the first issue's `path` and `message` and reads nothing else:
13+
14+
```
15+
PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared
16+
plugin contract at 'staticPath': PLUGIN_UI_REQUIRED_KEY_MISSING: a `type: 'ui'`
17+
plugin must declare `staticPath` — the absolute path of the static assets it
18+
serves. Declare it, or drop `type: 'ui'` if this plugin serves no assets.
19+
```
20+
21+
**The fix for an affected plugin** is the one the message names: declare both keys (`staticPath`: the absolute path of the assets it serves; `slug`: the URL segment it is mounted under), or drop `type: 'ui'` if the plugin serves no assets. There is no fallback to lean on: the Hono server's `slug || name.split('/').pop()` derivation is no longer reachable through the kernel, because the object is refused before it is stored.
22+
23+
**`@objectstack/core``Plugin` derives its metadata keys.** `Plugin` now `extends PluginDefinition` (`z.input<typeof PluginSchema>`), so `id`, `type`, `staticPath`, `slug`, `default`, `version`, `description`, `author` and `homepage` are ONE declaration shared with the schema the kernel enforces. Additive for every existing implementer: `type` and `version` keep the shapes they had (`type` is still `PluginType | undefined`, pinned type-equal in `packages/rest`; `version` still `string | undefined`), and the seven other keys are new optional members. A `ui` plugin can now carry `staticPath` / `slug` without widening its own type. Runtime-only members (`name`, `dependencies`, `optionalDependencies`, `requiresServices`, `providesServices`, `init`, `start`, `destroy`) stay declared on the interface.
24+
25+
**Blast radius, measured.** No in-repo plugin object outside test fixtures declares `type: 'ui'` (searched `packages/`, `apps/`, `examples/` non-dist sources for a `type` key or class field holding the literal `'ui'`: three test files, nothing shipped), so no in-repo composition changes behaviour. Externally authored `ui` plugins that relied on the slug derivation, or declared no assets, are the population this reaches — and they are refused at boot, by name, with the key to add.
26+
27+
<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing on plugin OBJECTS, which are never stored metadata: `PluginSchema` gains a refinement and one exported constant; no metadata key, object definition or stored representation is added, removed or renamed, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. The channel that reaches an affected plugin author is the refusal itself, which names the missing key at `kernel.use()`; which value that key should carry is authoring intent no ledger entry can decide. -->
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): mark `PromptTemplate.system` / `.user` `[EXPERIMENTAL — not enforced]` (#15954, #16321)
6+
7+
Prose only. `Clause-②: no` — no accept-set change, no new/narrowed authorable
8+
key, no matrix declaration. Every value that parsed before parses now, and
9+
every value refused before is refused identically.
10+
11+
Under the #15954 ruling (decision batch #56, option B) the template-typed pair
12+
is **marked, not retired**. Both `.describe()` strings on
13+
`ai/PromptTemplateSchema` now carry the repo's existing
14+
`[EXPERIMENTAL — not enforced]` prefix and state that no runtime renders or
15+
executes the template today:
16+
17+
```ts
18+
system: TemplateExpressionInputSchema.optional().describe('[EXPERIMENTAL — not enforced] System prompt — supports {{var}} interpolation. No runtime renders or executes the template today.'),
19+
user: TemplateExpressionInputSchema.describe('[EXPERIMENTAL — not enforced] User prompt template — supports {{var}} interpolation. No runtime renders or executes the template today.'),
20+
```
21+
22+
**Why an author sees this.** `PromptTemplateSchema` has no consumer outside
23+
`packages/spec`, so the `{{var}}` holes are never interpolated and the declared
24+
`variables` are never checked against them. The ADR-0058 D7 conformance ledger
25+
already recorded that verdict (`template-prompt`, `state: 'experimental'`,
26+
`PARSE ONLY — NO EVALUATOR FOUND`); until now nothing said it at the
27+
declaration, so the generated reference page advertised a capability the
28+
runtime does not deliver.
29+
30+
**What does NOT change.** `.user` remains **required** and `.system` remains
31+
optional — the schema shape is untouched. Optionalising or retiring a required
32+
key is a parse-breaking change and is deliberately left to its own card. No
33+
tombstone and no ADR-0087 entry is owed: nothing is renamed, retired or
34+
re-typed.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/trigger-schedule": minor
3+
"@objectstack/service-automation": minor
4+
"@objectstack/service-job": minor
5+
---
6+
7+
A scheduled (cron) flow is now delivered once per tick window, and replaying a window that was already delivered is refused instead of silently sent again.
8+
9+
A `time_relative` flow has taken a persisted dispatch claim per `(flow, window, record)` since #10220, so per-record once-only delivery is free for it. A `schedule` flow runs once per tick with no record and had no claim surface at all, so "this batch already went out" fell back to whatever each app remembered for itself. A scheduled digest that was replayed by an operator, or whose process restarted inside its window, delivered twice.
10+
11+
Scheduled flows now claim `(flow, tick-window)` in the same `sys_flow_dispatch` ledger, and settle that claim with what the run turned into:
12+
13+
- **A second fire inside one window does nothing.** The window key is a pure function of the schedule descriptor and the clock — the previous occurrence of the very same cron expression in the very same timezone, computed with the same library the job adapter schedules with — so a restart inside the window computes the same key and hits the same claim.
14+
- **`IJobService.replay()` refuses a delivered window**, with the ADR-0112 envelope its contract declares: `code: 'RESOURCE_CONFLICT'`, `status: 409`, and a message naming the window and the claim that refused it. The promise rejects — an operator who presses replay and sees nothing happen is exactly the outcome this replaces.
15+
- **`replay(name, data, { force: true })` sends anyway.** The duplicate is the operator's, taken knowingly.
16+
- **A window whose claim is absent, failed or unsettled re-runs** on a plain `replay()`, with no force needed. A job that takes no claim at all — every job that is not a scheduled flow — is the absent row and behaves exactly as before.
17+
- **`succeeded` is absorbing.** A replay that repairs a failed window records `succeeded`, so the next unforced replay is refused. A *forced* replay that throws leaves the window recorded delivered rather than rewriting it to `failed` — otherwise a failed re-send would silently reopen the unforced re-delivery door. An operator whose forced replay failed forces again.
18+
- **A `once` schedule now has a tick window too** — the single instant it is due, which is one window for the job's whole life. The visible consequence is on replay: an operator who replays a one-shot job *before* its due instant claims that single window, so the real fire then finds the claim and does nothing. Previously both ran.
19+
20+
The error-isolation `catch` that keeps a throwing flow from crashing the ticker is unchanged and still swallows. What it no longer does is leave the run indistinguishable from a delivered one: the throw settles the window's claim as `failed`, so a replay repairs it.
21+
22+
`sys_flow_dispatch` gains two optional columns, `outcome` and `settled_at`. Rows written before this release read as unsettled, which reads as not delivered — the safe direction, since a replay of one re-runs rather than being refused. Only `schedule:` claims are ever settled; a `time_relative` sweep's rows stay `null` by design.
23+
24+
⚠️ **If you manage this table's DDL out of band** — anything other than letting the platform sync `sys_flow_dispatch` from its object definition — add `outcome` (text) and `settled_at` (datetime) yourself before upgrading. Without them every `settle()` throws against the driver. Dispatch dedup still works and no flow fails (the settle is best-effort and logged), but no claim ever records an outcome, so the replay refusal never fires and this release's headline change is silently absent.
25+
26+
Interface changes for hosts that implement the ledger themselves:
27+
28+
- `FlowDispatchStore` gains **optional** `settle()` and `read()`. A store without them still deduplicates; it announces once that the refusal cannot fire.
29+
- `FlowDispatchStoreEngine` — the narrow ObjectQL slice the bundled store demands — now **requires** `update` alongside `find` and `insert`. A custom engine adapter typed against it must add the method.
30+
- New exported types: `FlowDispatchClaim` and `FlowDispatchOutcome` from `@objectstack/service-automation`; `ReplayGuard` and `ReplayGuardDecision` from `@objectstack/service-job` (the parameter type of `DbJobAdapter.setReplayGuard`, exported so it can be named); `ScheduleDispatchLedger`, `ScheduleDispatchClaim`, `ScheduleDispatchOutcome`, `ReplayGuard` and `ReplayGuardDecision` from `@objectstack/trigger-schedule`.
31+
- `croner` moves from a devDependency to a **dependency** of `@objectstack/trigger-schedule`, which now imports it at runtime to compute the cron tick window. It is already a runtime dependency of `@objectstack/service-job` at the same range, so the platform's dependency set does not grow.

0 commit comments

Comments
 (0)