Skip to content

Commit b8400f2

Browse files
authored
Merge branch 'main' into claude/issue-13799-self-test-roster-floor-b1b
2 parents ca1e17b + 431979e commit b8400f2

173 files changed

Lines changed: 11437 additions & 675 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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
refactor(cli): spell the action-dedup object-less key as `GLOBAL_ACTION_OBJECT_KEY` (#14669)
6+
7+
`os lint` dedups action declarations on the engine's composite registration key
8+
(`<objectName>:<name>`), and the object half of that key terminated on a bare
9+
`'global'` string literal in `lintConfig`'s `PREFIXED_TYPES` table. The engine's
10+
own writers stopped spelling the literal: PR #14667 converged
11+
`ObjectQLPlugin.actionObjectKey` onto the shared `GLOBAL_ACTION_OBJECT_KEY`
12+
constant for exactly this reason — a copy that agrees by value today is the one
13+
that parts from the writer in silence the day the constant moves, with no test
14+
in the repo able to see it. This reader now imports the constant from
15+
`@objectstack/objectql`, which `@objectstack/cli` already depends on.
16+
17+
**No behaviour moves.** `GLOBAL_ACTION_OBJECT_KEY` is `'global'`, so every key
18+
this table builds is byte-identical to the one it built before; the #5510 dedup
19+
suite (`lint-namespace-prefix.test.ts`, 15 declarations over 5 objects) passes
20+
unchanged. Only `objectName` is read, exactly as before — the `object`/`entity`
21+
aliases are still rejected upstream by `ActionSchema`'s strict shape and are
22+
deliberately not admitted here.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`composeStacks` now refuses two input stacks whose action declarations resolve to one scope-qualified runtime key — **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes.
6+
7+
**The refused shape:** two (or more) input stacks that each declare an action resolving to one runtime key — `objectName:name`, or `global:name` for an object-less action. The canonical case is two packages, each legal on its own, each declaring a global action named `shared_refresh`: `composeStacks([a, b])` used to accept the pair and emit `["global:shared_refresh", "global:shared_refresh"]`, and the runtime — which registers and dispatches every action under that one exact-string key — collapsed both handlers to one registration: whichever registered second won, and the other package's action stayed a live, declared, permission-gated button whose handler was unreachable. `defineStack` refuses exactly this collision within one stack (#14686); composition was the second door in the same file that let it through.
8+
9+
The refusal carries `composeStacks`' conflict prefix and `defineStack`'s envelope shape — `composeStacks conflict: cross-stack action key collision (N issue(s)):`, one `✗` line per colliding key — and every line names the key, **both source stacks by manifest id** (`'com.example.a' (stack #0)`; a manifest-less input is named by position), and where each declaration sits (`stack.actions[i]`, or `objects['OBJECT'].actions[j]` for an embedded one). The fix is the one the message names: rename one of the colliding actions within its scope, bind one of them to a different object, or remove the duplicate from one of the stacks. ⛔ There is no `actionConflict` option and none is coming: `composeStacks` does not pick a winner for actions (maintainer ruling, 2026-09-03).
10+
11+
What the check judges is what composition **carries**: standalone actions from every stack (they concatenate), and each composed object's embedded actions attributed to the one stack whose object `objectConflict` handed the `actions` array to. So an embedded action that `'override'` or `'merge'` did not carry into the composed object cannot collide — that loss is the object strategy's own semantics — while a standalone action bound to an object from one stack does collide with an embedded action of the same name the composed object carries from another. Only a key declared by two or more **distinct** stacks is reported: a key an input repeats within itself is `defineStack`'s door (`strict: false` opts out there by choice), and an input built by `defineStack` legitimately carries each bound standalone action twice — as the copy the build appended to its object — which is never a collision with itself.
12+
13+
Deliberately unchanged, as in `defineStack`: one global and one object-bound action may share a name across stacks (two keys), and one name bound to two different objects is two keys. The shipped composer, `examples/app-multi-package`, declares no colliding key and composes unchanged; no `composeStacks` caller exists in objectui or hotcrm.
14+
15+
**Migration.** A composition refused by the new check must resolve the collision in one of the two packages — rename the action within its scope, bind it to a different object, or drop the duplicate. Which package keeps the name is an authoring decision the metadata cannot make for you.
16+
17+
<!-- adr-0087: not-required (no-migration-prescription) a validity narrowing over existing declarations: no key is removed, renamed or re-shaped and no export moves, so the ledger has no rewrite to carry; the refusal itself names the remedy at the composition site, and choosing which of two packages keeps the action name is an authoring decision no migration entry can perform on an upgrader's behalf. -->
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/lint": patch
4+
"@objectstack/mcp": patch
5+
---
6+
7+
fix(ai): author the CANONICAL agent id everywhere the platform teaches one — Studio's pin, the MCP prompt example, and the lint's value roster (#14461)
8+
9+
`skills/objectstack-ai` tells authors that `data_chat` and `metadata_assistant`
10+
"are **not** vocabulary — always write `ask` / `build`". The platform then
11+
taught the opposite from every live example it ships. Nothing was broken at
12+
runtime; what was wrong is what an author copies.
13+
14+
**Studio's pin.** `studio.app.ts` was the repo's ONLY `app.defaultAgent` usage,
15+
and it spelled the alias:
16+
17+
```
18+
- defaultAgent: 'metadata_assistant',
19+
+ defaultAgent: 'build',
20+
```
21+
22+
The triage card left this undecidable — if the cloud plugin registered the
23+
agent under the legacy id, re-pinning would be a behaviour change in a
24+
consumer this repo cannot see. Measured instead of assumed, at `cloud`
25+
`main@3856fbf7`: `service-ai-studio/src/agents/metadata-assistant-agent.ts:12,40`
26+
ships the record as `name: BUILD_AGENT_NAME` = `'build'`, and `plugin.ts:58`
27+
registers `metadata_assistant` as a **one-way, resolution-only** legacy alias.
28+
The canonical id *is* `build`; the old pin reached it by detour.
29+
30+
Nor is the re-pin cosmetic. Alias resolution depends on an in-memory
31+
`registerAgentAlias` call having run at plugin init, and cloud carries two
32+
defensive docblocks about that registration silently no-op'ing for real under
33+
bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
34+
`service-ai/src/agent-runtime.ts:30-41` — "a missed alias must never hide a
35+
real platform agent like `build`"). The canonical id never touches the alias
36+
table, so this drops a load-order dependency from the platform's own flagship
37+
authoring surface. On the UI side nothing moves: `objectui`'s
38+
`AGENT_ALIAS_GROUPS` is bidirectional and canonical-first, and
39+
`SURFACE_DEFAULT['studio-build']` was already `'build'`.
40+
41+
**The MCP prompt example.** `mcp-server-runtime.ts`'s `agent_prompt` argument
42+
described itself as `'Name of the agent to load (e.g. "data_chat",
43+
"metadata_assistant")'` — two retired aliases, neither canonical id present.
44+
That string is served to every MCP client asking what to pass, so the one
45+
surface that suggests a spelling to an LLM suggested the two the catalogue
46+
forbids. Now `(e.g. "ask", "build")`.
47+
48+
**The lint's value roster.** `validate-ai-agent-authoring`'s `defaultAgent`
49+
**value** limb reused the four-name `PLATFORM_AGENT_NAMES` set, so it
50+
deliberately passed `metadata_assistant` — the gate that exists to make
51+
authoring mistakes loud waved through the exact spelling the catalogue bans,
52+
which is the silent-tolerance shape ADR-0078 exists to close, committed by the
53+
gate itself. The two limbs now read different tables, because they ask
54+
different questions:
55+
56+
- **declaration limb** — unchanged, still all four names. Declaring
57+
`metadata_assistant` shadows the `build` record through the alias exactly as
58+
declaring `build` does.
59+
- **value limb** — canonical `ask` / `build` only. A legacy alias gets its own
60+
rule id `default-agent-legacy-alias` (exported) and its own wording, because
61+
an alias **resolves** (the app gets the agent it meant — a spelling defect)
62+
while an unknown name does **not** (the pin is inert). Describing the alias
63+
as "no effect" would send an author hunting a bug that is not there.
64+
65+
Both of the #6041 ruling's operative decisions are kept intact: still
66+
`warning` tier, still no Zod enum narrowing. `defaultAgent: 'metadata_assistant'`
67+
keeps parsing, building, and resolving — the only change is that authoring it
68+
now says so.
69+
70+
Not breaking: nothing an author can write was removed, and both aliases stay
71+
resolvable for old bookmarks and persisted `agent_id`s, which is the only job
72+
ADR-0063 §2 ever gave them.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
Fix `os explain flow`, whose example taught a flow shape the spec rejects and an assignment value nothing resolves.
6+
7+
`os explain` is an authoring aid whose whole audience is authors — increasingly AI authors — writing their first flow, and its catalog is hand-maintained rather than derived from `FlowSchema`. The flow entry had drifted until the sample it printed could not be pasted into a working app:
8+
9+
- `steps` and `trigger` are strict-object **aliases** on `FlowSchema` (for `nodes` and `type`), so authoring either is a loud parse error rather than a working flow. A record-change flow binds its object on the START node's `config` (`{ objectName, triggerType }`), not at the flow top level.
10+
- A node's per-type data lives under `config`, so the sample's top-level `field`/`value` pair were undeclared keys on a `.strict()` node schema, and the required `id` / `label` were absent. `edges` is required, and the sample declared no graph at all.
11+
- The value `'$currentUser'` was a `$`-prefixed sentinel no resolver in the platform recognises. Flow values interpolate with **single braces**, and the acting user is `{$User.Id}` — the filter surface's `{current_user_id}` is a different dialect that does not carry over, because assignment and `fields` values go through `interpolate`, not `interpolateFilter`.
12+
- An `assignment` node sets a flow **variable**, not a record field, so "auto-assign on create" is an `update_record` node. The old sample would not have written `assigned_to` even with a resolving token.
13+
14+
The entry's field list now matches `FlowSchema` (`nodes` / `edges` / the full five-value `type` enum / `status` / `runAs`), and the example is pinned by a test that parses it against `FlowSchema` — the one guard that cannot drift alongside the catalog it checks.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): a top-level flow `trigger` / `triggerType` is now sent to the START node's `config`, not to a `type` rename
6+
7+
`FlowSchema`'s alias table pointed both keys at `type`, so a flow carrying a
8+
top-level trigger block was refused with the rename `trigger` → `type`.
9+
That rename cannot be taken: `type` is the flow KIND
10+
(`autolaunched` | `record_change` | `schedule` | `screen` | `api`), so an author
11+
who followed the advice landed on
12+
`Invalid option: expected one of "autolaunched"|…` one round later, with the
13+
trigger binding still nowhere — and a `.strict()` refusal carries exactly one
14+
actionable sentence.
15+
16+
The trigger does not move to `type`. It binds on the START node's `config`, as
17+
`{ objectName, triggerType, condition }` with a `record-*` token such as
18+
`record-after-create` — the shape the automation engine and the authoring-time
19+
`resolveFlowTriggerKind` both read. Both keys are `guidance` entries now, beside
20+
the `object` / `objectName` / `schedule` prescriptions that already name that
21+
config, so the rejection says where the binding really lives instead of
22+
prescribing a name:
23+
24+
```
25+
Unrecognized key(s) on this flow: `trigger`.
26+
• `trigger` is not a Flow field — a record-change flow binds its trigger on
27+
the START node's `config` (`{ objectName, triggerType, condition }`, where
28+
`triggerType` is a `record-*` token such as `record-after-create`), not at
29+
the flow top level; the flow-level `type` names the flow kind
30+
(`record_change`), not the binding.
31+
```
32+
33+
No accept/reject behaviour changes: a top-level `trigger` / `triggerType` was
34+
refused before and is refused now, and `FlowSchema`'s accepted keys and its
35+
`type` enum are untouched — only the prescription the refusal carries. One
36+
measured consequence of dropping the alias row: the guidance channel matches the
37+
exact authored spelling (case folding is the rename channel's job), so a
38+
non-canonical spelling such as `triggertype` now gets the bare rejection rather
39+
than the rename it cannot take.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os generate` now has an answer for every field type, instead of silently guessing
6+
7+
Three hand-kept vocabularies in `generate.ts` decide what `os generate types`
8+
and `os generate migration` emit for a field: the TypeScript type, the SQL
9+
column type, and the knex builder call. None of them was ever checked against
10+
the `FieldType` enum they describe, and measured against the 49 members on
11+
`main`, **21 real members had no entry in either lookup table and 24 had no arm
12+
in the migration switch**.
13+
14+
An unmapped member did not fail — it fell to the default. So a `secret` field
15+
scaffolded as TypeScript `unknown` and a `TEXT` column, a `location` as
16+
`unknown` and `TEXT`, and `address` / `composite` / `repeater` / `record` — all
17+
four stored as JSON on the parent row — as scalar `TEXT` columns. The output
18+
looked plausible and nothing said otherwise, which is what made this worth
19+
fixing rather than tidying.
20+
21+
All 49 members now have an entry in all three, and the values are read off the
22+
platform rather than invented: the spec's ADR-0104 D1 value classes
23+
(`STRING_VALUE_TYPES`, `NUMERIC_VALUE_TYPES`, `STRUCTURED_JSON_TYPES`, …) decide
24+
the class, and `driver-sql`'s own DDL emitter — which creates the real columns —
25+
decides the shape. `location` becomes a JSON column, not a `POINT`: that is what
26+
the driver does, `POINT` is not portable to SQLite, and the spec's own value
27+
contract for it is `{lat, lng, altitude?, accuracy?}`. `location` and `address`
28+
now emit the spec's exported `Data.LocationValue` / `Data.AddressValue` types,
29+
so the generated interface cannot drift from the value contract.
30+
31+
The gap can no longer reopen quietly. Both lookup tables are
32+
`satisfies Record<FieldType, string>`, so a field type added to the spec is a
33+
named compile error here; the switch — whose scrutinee is a plain string off an
34+
unvalidated config and so cannot carry one — is held by
35+
`generate-field-type-vocabulary.pin.test.ts`, which walks the real enum and
36+
names any member left unmapped.
37+
38+
The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) are unchanged
39+
and still reachable: they answer a `type` string that is not a field type at
40+
all, which the unvalidated authoring door can still deliver.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
security lint: report a `controlled_by_parent` object whose master is decided by FIELD DECLARATION ORDER (#14747)
6+
7+
`SecurityPlugin.resolveCbpRelation` resolves the master a `controlled_by_parent`
8+
object derives record-level access from through three tiers — a required
9+
`master_detail`, then any `master_detail`, then a required `lookup` — and picks
10+
inside a tier with `Array.prototype.find`. So when two or more candidates sit in
11+
the tier that wins, the master is whichever one the field map happens to list
12+
first. Measured on a real kernel: an object declaring two required lookups
13+
resolved its security master to the first-declared one, and swapping the two
14+
field declarations — nothing else — repointed every row's record-level access
15+
to the other object. Nothing reported it: not `os validate`, not `os lint`, not
16+
a boot warning.
17+
18+
New error id **`security-controlled-by-parent-ambiguous-relation`**, the mirror
19+
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
20+
ZERO candidates, this one reports two or more. The message names every
21+
candidate — field, type and master — in declaration order, says which tier was
22+
tested, and says which candidate wins today and therefore which object access
23+
derives from right now.
24+
25+
Only the **winning** tier is judged, and that is not a shortcut: the runtime's
26+
`??` chain stops at the first tier that resolves, so a tie in a lower tier is
27+
masked by a higher tier's single winner and is not a decision the platform ever
28+
makes. An object with one required `master_detail` and two required lookups is
29+
silent, and stays silent.
30+
31+
`error` rather than advisory, for the inverse of the usual reason. The other
32+
error rules in this linter mirror a hard runtime refusal; this one has none to
33+
mirror precisely BECAUSE the runtime does not refuse — it silently picks — so
34+
author time is the only place the ambiguity can ever surface. What it does meet
35+
is the admissibility bar the #7503 rule states: a self-contained property of the
36+
object document, no per-permission-set nuance to adjudicate, and no legitimate
37+
reading, since two tied candidates is not an author saying which master they
38+
meant.
39+
40+
This **narrows the accept set of a gating rule** — error findings fail
41+
`os validate` / `os compile`. Measured over the shipped corpus: the three
42+
`controlled_by_parent` objects in the example apps (`showcase_invoice_line`,
43+
`showcase_expense_line`, `crm_opportunity_line_item`) plus the 27
44+
`ObjectSchema.create` sites the `check:doc-security-posture` gate reads across
45+
226 marked prose blocks — **0 findings before and 0 after**. Each of the three
46+
declares exactly one required `master_detail`, so tier 1 wins with a single
47+
candidate. `showcase_invoice_line` is the interesting one: it also carries a
48+
required `lookup`, and the rule is silent because that tie-free lower tier is
49+
never reached.
50+
51+
No runtime behaviour changes. `resolveCbpRelation` in this package now reads its
52+
tiers from one shared table so the two rules cannot disagree about which tier
53+
wins, and its answer is unchanged by construction: `find` over a tier is the
54+
first element `filter` over that tier keeps. The mirror's one deliberate
55+
divergence from the runtime is kept — `reference` is the only spelling accepted
56+
here (#5017), so a field carrying the rejected `reference_to` alias is not a
57+
candidate and cannot create a tie.

0 commit comments

Comments
 (0)