Skip to content

Commit b08e6db

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/fieldnode-nested-select-form-tygnzg
2 parents 53f8134 + 6e357ed commit b08e6db

31 files changed

Lines changed: 1326 additions & 271 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
docs(ui,protocol): document the six navigation / knowledge-source variants that the #4177 variant/doc gate was only ever name-checking. `apps.mdx` enumerated nine navigation item types but gave `report`, `action` and `component` a single shared sentence instead of sections; `knowledge.mdx` named `object` / `file` / `http` in a table with no example of any, leaving the per-kind keys undocumented. Each example was parsed against the real schema (`NavigationItemSchema`, `KnowledgeSourceKindSchema`) before landing. Documentation only; releases nothing.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/service-automation": major
4+
---
5+
6+
fix(spec,service-automation)!: `errorHandling.maxRetries` has one default, and `strategy: 'retry'` states its count (#4247)
7+
8+
`flow.errorHandling.maxRetries` was declared twice, with different values:
9+
10+
- **spec**`FlowSchema` (`automation/flow.zod.ts`): `.default(0)`
11+
- **engine**`retryExecution` (`service-automation/src/engine.ts`):
12+
`errorHandling.maxRetries ?? 3`
13+
14+
`??` fires only on `undefined`, so the winner was decided by the ROUTE a flow
15+
took into the engine, not by what its author wrote:
16+
17+
| Path | `errorHandling.maxRetries` | Retries |
18+
|:---|:---|---:|
19+
| parsed by `FlowSchema` (`.default(0)` fills it) | `0` | **0** |
20+
| object built by hand and fed to the engine | `undefined` | **3** |
21+
22+
One authored intent — "I didn't write a count" — two behaviors. The neighbouring
23+
`retryDelayMs ?? 1000` / `backoffMultiplier ?? 1` agreed with their `.default()`s;
24+
only `maxRetries` disagreed, which reads as a schema default changed from 3 to 0
25+
without the engine following, not as a deliberate two-track design.
26+
27+
**The engine keeps no defaults of its own.** `retryExecution` now takes the
28+
parsed `NonNullable<FlowParsed['errorHandling']>` and destructures all five
29+
knobs — no `??`. This is safe because `AutomationEngine.flows` only ever holds
30+
`FlowSchema.parse` output (`registerFlow` parses; the version-history rollback
31+
re-seats an already-parsed snapshot), and it is what keeps a second set of
32+
defaults from growing back: a knob the spec stops defaulting becomes a compile
33+
error rather than a silent engine-side guess. Per Prime Directive #12 the spec
34+
is the one contract; a consumer-side fallback is a second de-facto one.
35+
36+
**BREAKING — `strategy: 'retry'` now requires `maxRetries` >= 1.** With the
37+
engine's copy gone, an unstated count is unambiguously `0`, and `'retry'` with 0
38+
attempts runs the flow once and stops — i.e. `strategy: 'fail'` wearing another
39+
label, a declared capability the runtime does not deliver (Prime Directive #10
40+
corollary). Rather than pick 0 or 3 on the author's behalf, `FlowSchema` refuses
41+
the combination in both spellings (omitted → defaulted 0, and an explicit 0),
42+
with the prescription in the message. A retry re-runs the **whole flow from the
43+
start** — records created again, callouts fired again — which is not a number to
44+
guess for someone.
45+
46+
FROM → TO:
47+
48+
- `errorHandling: { strategy: 'retry' }``errorHandling: { strategy: 'retry', maxRetries: 3 }`
49+
(or `strategy: 'fail'` if no retry was intended — that is what it did).
50+
- `errorHandling: { strategy: 'retry', maxRetries: 0 }` → same choice, spelled out.
51+
52+
Unaffected: `maxRetries: 0` under `strategy: 'fail'` / `'continue'` (neither
53+
reads it, and a fully spelled-out block stays legal), flows with no
54+
`errorHandling` at all, and every flow that already states a count — including
55+
the `try_catch` node's own `config.retry`, which is a separate per-region policy
56+
(`control-flow.zod.ts`) and is unchanged.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
test(spec): pin the input half of every recursive schema, so the third omission fails instead of shipping (#3786)
6+
7+
A recursive Zod schema cannot infer its own type, so it carries a hand-written
8+
`z.ZodType<...>` annotation. `z.ZodType` takes **two** type parameters,
9+
`<Output, Input>`, and `Input` defaults to `unknown`. Naming only the first
10+
compiles, validates correctly at runtime, and silently un-types every authoring
11+
path through the schema — `unknown` accepts everything.
12+
13+
This package has made that mistake twice:
14+
15+
1. **#4171** replaced `z.ZodType<any>` on the nav union with
16+
`z.ZodType<NavigationItem>`, fixing the output half. `check-exported-any.ts`
17+
was built to hold that fix and reads output only, so it reported green over
18+
the half that was still broken.
19+
2. **#4221** found the consequence — `defineApp`, the documented authoring entry
20+
point, compiled `navigation: [{ totally: 'made up' }, 42, 'nonsense']` clean —
21+
and **#4227** then named both parameters on the six remaining recursive
22+
schemas.
23+
24+
Both fixes are correct and both are currently unpinned. #4227 considered a
25+
`.d.ts`-level scanner and declined it for a good reason: separating a deliberate
26+
single-parameter `z.ZodType<T>` (the generic in `contracts/llm-adapter.ts` takes
27+
a caller-supplied schema, where the input side is nobody's business) from an
28+
omission needs heuristics on emitted type names, and `check-exported-any.ts`'s
29+
own rule is zero false positives so red keeps meaning broken. Its commit
30+
nominated #4221's assertion-file pattern instead. This is that pattern, applied
31+
to the eight schemas #4221 did not cover: `QuerySchema`, `JoinNodeSchema`,
32+
`FieldNodeSchema`, `FilterConditionSchema`, `NormalizedFilterSchema`,
33+
`StateNodeSchema`, `ValidationRuleSchema`, `FormFieldSchema`.
34+
35+
`src/recursive-schema-input-assertions.ts` gives each one a positive probe (the
36+
authoring shape still compiles — guarding an input type drawn too tight) and a
37+
negative probe reached **through `z.input<typeof Schema>`**, the way a consumer
38+
gets there. The negative is load-bearing: it is a value `unknown` would accept
39+
and the real type rejects, so dropping a type parameter turns the suppression
40+
unused and `tsc --noEmit` fails on that line, by name.
41+
42+
Verified by mutation in both shapes a regression can take:
43+
44+
- `QuerySchema` back to one parameter → the pin fires **and** `JoinNodeSchema`
45+
cascades a type error, because `JoinNodeInput.subquery` is `QueryInput`.
46+
- `StateNodeSchema` back to one parameter → **only** the pin fires. Nothing else
47+
in the package references its input, so without this file that regression is
48+
completely silent. That is the case the file exists for.
49+
50+
No runtime change, no new public export (`check:api-surface` reports no diff);
51+
the module is referenced by no tsup entry and re-exported by no barrel.
52+
53+
Also classifies `check:strictness-ledger` in `check-generated.ts`'s ledger. It
54+
landed in #4232 without an entry, so `check:generated` was failing on `main`
55+
itself — the same cross-PR race the `check:variant-docs` entry above it already
56+
documents, now on its second occurrence.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
test(runtime): correct the #4073 evidence — the `registerStandardEndpoints` flip IS a no-op for a composed host
6+
7+
#4192 added a test concluding that turning the flag off makes `/api/v1/data/:object`
8+
a 404, and blocked the #4073 retirement on it. That conclusion was wrong.
9+
10+
It mounted `createRestApiPlugin({})` against a STUB `objectql` service. REST
11+
generates CRUD from the object registry, so it needs a real engine — driver plus
12+
registered objects — and its own `api.api` config. Under-provisioned it serves
13+
nothing, which says nothing about REST.
14+
15+
Provisioned the way `client.environment-scoping.test.ts` does it (that suite runs
16+
`registerStandardEndpoints: false` and asserts `GET /api/v1/data/task` → 200 from
17+
REST), `/data/:object`, `/discovery` and `/.well-known/objectstack` all return
18+
byte-identical responses with the flag on and off.
19+
20+
The test now asserts that parity directly rather than a status code, because a
21+
status was what misled it: `/data/task` answers 404 `OBJECT_NOT_FOUND` here — the
22+
engine's answer, i.e. a route that WORKS — where a routing miss would be
23+
`{"error":"Not found"}`. A separate assertion pins that the compared routes are
24+
live, so parity cannot be satisfied by two identical misses.
25+
26+
No production code changes. The default is untouched: flipping it is still a real
27+
change for a BARE host mounting neither REST nor the dispatcher, and that is an
28+
API decision, not one this test makes.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
"@objectstack/service-storage": patch
4+
"@objectstack/cli": patch
5+
---
6+
7+
feat(platform-objects,service-storage,cli): `sys_migration` is platform infrastructure — registered by `PlatformObjectsPlugin`, not by the storage service (#4243)
8+
9+
The deployment-level data-migration flag ledger (`sys_migration`, #3617) was
10+
registered by `@objectstack/service-storage` as its first consumer. That was
11+
deliberate while the file migration was the only consumer, but the ledger now
12+
gates storage-independent behaviour too — `os migrate value-shapes` (#4235)
13+
and the fresh-datastore attestation (#4215) — and a non-file migration had to
14+
boot the whole storage plugin just so the kernel carried the table. Any kernel
15+
assembled without storage silently had no ledger at all, which read exactly
16+
like "migration not run" (both answer false) while actually meaning "ledger
17+
not installed".
18+
19+
The registration now lives in `PlatformObjectsPlugin`
20+
(`@objectstack/platform-objects/plugin`) — the plugin `os serve` already
21+
auto-injects into every served kernel — so the ledger exists with the
22+
platform, independent of which optional services are composed. The
23+
fresh-datastore attestation (#3438, ADR-0104) moves with it: it is ledger
24+
bookkeeping, and its old home justified itself as "the service that registers
25+
`sys_migration`". Definition ownership is unchanged (`sys_migration` stays in
26+
`@objectstack/platform-objects` and in `PLATFORM_OBJECTS_BY_PACKAGE`); the
27+
flag helpers and readers are untouched.
28+
29+
Consequences:
30+
31+
- `@objectstack/service-storage` no longer contributes `sys_migration` to the
32+
manifest and no longer performs the fresh-datastore attestation. An embedder
33+
composing `StorageServicePlugin` on a hand-built kernel that relied on it
34+
for the ledger must compose `PlatformObjectsPlugin` (the plugin every
35+
supported assembly path already includes).
36+
- The CLI's `buildDataMigrationPlugins()` no longer boots storage for every
37+
gated migration — it registers `PlatformObjectsPlugin` always, and settings
38+
+ storage only for `os migrate files-to-references` (`{ storage: true }`),
39+
the one migration that actually reconciles against the storage adapter.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
---
3+
4+
test(objectql): pin the two vacuous-filter carve-outs (#4121, #4181)
5+
6+
Test-only; no package behavior changes, so this changeset releases nothing.

content/docs/automation/flows.mdx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,7 @@ The two recovery mechanisms operate at different scopes and do not compound:
665665
| | Scope | On failure |
666666
| :--- | :--- | :--- |
667667
| `fault` edge | one node | traversal continues from the handler; the run completes |
668-
| `errorHandling: { strategy: 'retry' }` | the whole flow | the flow re-runs **from the start** |
668+
| `errorHandling: { strategy: 'retry', maxRetries: n }` | the whole flow | the flow re-runs **from the start**, up to `n` more times |
669669

670670
A failure a fault edge handled is not a flow failure, so it does **not** consume
671671
a retry. That matters because flow-level retry replays every node that already
@@ -707,13 +707,46 @@ errorHandling: {
707707

708708
| Property | Type | Description |
709709
| :--- | :--- | :--- |
710-
| `strategy` | `enum` | `'fail'` (stop) or `'retry'` (re-run the whole flow). `'continue'` parses but the engine branches only on `'retry'`, so it behaves exactly like `'fail'` — use a `fault` edge to keep going past a failed node |
711-
| `maxRetries` | `number` | Maximum retry attempts (0-10) |
712-
| `retryDelayMs` | `number` | Delay between retries (ms) |
710+
| `strategy` | `enum` | `'fail'` (stop) or `'retry'` (re-run the whole flow). `'continue'` parses but the engine branches only on `'retry'`, so it behaves exactly like `'fail'` — use a `fault` edge to keep going past a failed node (default `'fail'`) |
711+
| `maxRetries` | `number` | Retry attempts **after** the initial one, `0``10`. Under `strategy: 'retry'` it must be at least `1` and there is no default — see below (default `0`, i.e. no retries, for the strategies that never retry) |
712+
| `retryDelayMs` | `number` | Delay between retries (ms) (default `1000`) |
713713
| `backoffMultiplier` | `number` | Exponential backoff multiplier (default `1`) |
714714
| `maxRetryDelayMs` | `number` | Ceiling on the backed-off delay (default `30000`) |
715715
| `jitter` | `boolean` | Randomize the delay to avoid a thundering herd (default `false`) |
716716

717+
`maxRetries` counts the **re-runs**, not the total attempts: `maxRetries: 2`
718+
runs the flow up to three times.
719+
720+
### `strategy: 'retry'` has to say how many times
721+
722+
There is no default retry count. `strategy: 'retry'` without `maxRetries` — or
723+
with `maxRetries: 0` — is refused when the flow is registered:
724+
725+
```typescript
726+
// ❌ rejected: "retry" that retries zero times is just "fail"
727+
errorHandling: { strategy: 'retry' }
728+
729+
// ✅ state the attempts
730+
errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 5000 }
731+
```
732+
733+
A retry re-runs the **whole flow from the start**, so every node that already
734+
succeeded runs again — records get created again, callouts fire again. That is
735+
too consequential a number to pick on the author's behalf, and picking `0`
736+
would make opting into `'retry'` do nothing at all. The knobs above are read
737+
only under `'retry'`; a fully spelled-out block under `'fail'` or `'continue'`
738+
is fine and simply ignored.
739+
740+
<Callout type="info">
741+
Before ObjectStack 17 the count depended on how the flow reached the engine:
742+
a flow parsed by `FlowSchema` retried `0` times when the count was unstated,
743+
while a hand-built definition passed straight to the engine retried `3` — the
744+
schema and the engine each carried a default and they disagreed
745+
([#4247](https://github.com/objectstack-ai/objectstack/issues/4247)). The
746+
engine's copy is gone; the schema is the only source, and the case that was
747+
ambiguous is now rejected instead of guessed.
748+
</Callout>
749+
717750
## Discovery & Registration
718751

719752
You almost never call `engine.registerFlow()` directly. The

content/docs/protocol/knowledge.mdx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,34 @@ Three source kinds:
119119
| `file` | A folder in `IStorageService` | Onboarded PDFs, uploads |
120120
| `http` | A list of remote URLs | External docs, RSS, sitemaps |
121121

122+
`kind` is the discriminator, and each kind carries its own keys — a key from one
123+
kind is rejected on another:
124+
125+
```typescript
126+
// object — content comes from the named fields of each matching record
127+
source: {
128+
kind: 'object',
129+
object: 'kb_article',
130+
contentFields: ['title', 'body'], // at least one, required
131+
metadataFields: ['category', 'author'], // optional, carried onto the document
132+
where: { status: 'published' }, // optional ObjectQL filter
133+
}
134+
135+
// file — every object under a storage prefix
136+
source: {
137+
kind: 'file',
138+
prefix: 'knowledge/handbook/',
139+
mimeTypes: ['application/pdf', 'text/markdown'], // optional; omit to take all
140+
}
141+
142+
// http — an explicit URL list (each must be a valid URL)
143+
source: {
144+
kind: 'http',
145+
urls: ['https://docs.example.com/guide', 'https://docs.example.com/faq'],
146+
userAgent: 'ObjectStack-KnowledgeBot/1.0', // optional
147+
}
148+
```
149+
122150
Every source binds to a named **adapter id** (e.g. `'ragflow'`,
123151
`'memory'`). The adapter id is resolved at runtime by the plugin that
124152
registers itself with that name. Adapter config (endpoint, dataset id,

content/docs/releases/v17.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,17 @@ from `@objectstack/spec` directly.
930930
- **`sys_view_definition`'s all-six `apiMethods` whitelist is dropped** (#3026).
931931
- **`os migrate plan` shows index drift** — index DDL is no longer applied
932932
silently at boot (#3728).
933+
- **A flow's `errorHandling.strategy: 'retry'` must state `maxRetries` (>= 1)**
934+
(#4247). `maxRetries` had two defaults — `.default(0)` in `FlowSchema` and
935+
`?? 3` in the engine's `retryExecution` — so an unstated count retried 0 times
936+
for a flow that had been through the schema and 3 times for a definition
937+
handed to the engine directly. The engine's copy is gone (it reads the parsed
938+
block with no fallback), and the case that was ambiguous is rejected rather
939+
than guessed: retrying zero times is `strategy: 'fail'` under another name,
940+
and a retry re-runs the *whole* flow, so the count is the author's to state.
941+
Fix: write `{ strategy: 'retry', maxRetries: 3 }`, or `strategy: 'fail'` if no
942+
retry was intended. `maxRetries: 0` stays legal under `'fail'` / `'continue'`,
943+
which never read it.
933944

934945
## New capabilities in 17.0.0
935946

content/docs/ui/apps.mdx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ const crmApp = {
5757

5858
## Navigation Items
5959

60-
The navigation tree supports nine item types, combined to create rich menu structures. The most common are shown below (`object`, `dashboard`, `page`, `url`, `group`, `separator`); the spec also defines `report`, `action`, and `component` items.
60+
The navigation tree supports nine item types, combined to create rich menu structures: `object`, `dashboard`, `page`, `url`, `report`, `action`, `component`, `group` and `separator`. Each is documented below.
61+
62+
`type` is the discriminator: a value outside that list is rejected with the full
63+
set of valid ones, and every other key is checked against the branch you picked
64+
rather than against all nine.
6165

6266
### Object Navigation
6367

@@ -121,6 +125,48 @@ Groups items into collapsible sections with children:
121125
}
122126
```
123127

128+
### Report Navigation
129+
130+
Links to a saved report:
131+
132+
```typescript
133+
{ id: 'nav_pipeline', type: 'report', label: 'Pipeline Report', reportName: 'sales_pipeline', icon: 'file-bar-chart' }
134+
```
135+
136+
### Action Navigation
137+
138+
Runs an action instead of navigating to a surface. The reference lives in a
139+
nested `actionDef` block — `actionName` is **not** a top-level key on the item:
140+
141+
```typescript
142+
{
143+
id: 'nav_import',
144+
type: 'action',
145+
label: 'Import Records',
146+
icon: 'upload',
147+
actionDef: {
148+
actionName: 'bulk_import',
149+
params: { objectName: 'account', mode: 'upsert' },
150+
},
151+
}
152+
```
153+
154+
`actionDef` accepts only `actionName` and `params`. `params` itself is **open by
155+
design** — the action owns its own parameter contract, so the app schema does
156+
not validate what goes inside it.
157+
158+
### Component Navigation
159+
160+
Renders a registered component. `componentRef` is a component-registry key, not
161+
a file path:
162+
163+
```typescript
164+
{ id: 'nav_directory', type: 'component', label: 'Directory', componentRef: 'metadata:directory', icon: 'contact' }
165+
```
166+
167+
`params` is handed to the component as props and is **open by design** — the
168+
props are the component's own contract.
169+
124170
### Separator
125171

126172
A visual divider in the navigation list. It renders no target and carries no

0 commit comments

Comments
 (0)