Skip to content

Commit c291fc7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16645-analytics-native-sql-authz
#16755 landed on main and reached the one file both diffs touch, content/docs/permissions/system-context.mdx, exactly as the cross-PR pile-up patrol predicted on this PR. The merge driver declined to defer it (MIXED — a generated half plus hand-written prose) and text-merged it into a conflict. Resolved by hand in the only direction that keeps both sides' prose: main's enriched automation row is adopted whole, including its fourth anchor `#refuseUngrantedRunLifecycleWrite` and the operator run-lifecycle sentence, carried onto this branch's row number (this branch inserted a row upstream in the same table, so every row below it is offset by one). The census counts are re-derived from the merged tree in the follow-up commit, per the driver's own instruction and the regen ordering rule — never regenerated while in MERGE state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
2 parents 0882584 + 44c849c commit c291fc7

55 files changed

Lines changed: 3180 additions & 487 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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/driver-sql": patch
4+
---
5+
6+
fix(spec): withdraw the `field-required-notnull-explicit` ADR-0087 conversion — `required: true` no longer stamps `storage.notNull: true` on anybody's fields (#16693)
7+
8+
ADR-0113 split the pre-17 `required` tri-binding on purpose: `required` is the **write-time contract** and is NOT a column constraint, and `storage.notNull` alone binds the physical column (`sql-driver.ts#createColumn` has keyed off it alone since that ADR's P0). The `field-required-notnull-explicit` conversion asserted exactly the implication the ADR abolished — it added `storage: { notNull: true }` to every field it found `required: true` on — so it is removed from the conversion registry and from protocol 17's ADR-0087 ledger entry.
9+
10+
**Who this was reaching, and why it was not confined to old artifacts.** The entry carried `retiredFromLoadPath: true` and a docblock stating that "only `os migrate meta --from <16 or lower>` may apply it". That was not true of this tree. The artifact-ingestion door replays the whole chain with `includeRetired: true` (`applyArtifactForwardConversions`, `@objectstack/metadata-core`) and keys the replay off the artifact's declared `engines.protocol` **floor**, not its age — so any artifact declaring `^17.0.0`, which is the range `create-objectstack` stamps, was converted at boot. Measured on this tree at that seam: an artifact declaring `^17.0.0` on a 17.3.0 runtime came back from the door with `storage.notNull: true` written onto a field its author wrote as nullable-and-write-gated, and the boot logged `converted N site(s) forward` with a remedy sentence telling the author to write the same tightening into the source. On a populated database that instruction is a `tighten_not_null` / `severity: error` / `category: destructive` migration — prescribed as the remedy for a deprecation notice.
11+
12+
**What moves for consumers.**
13+
14+
- `applyConversions(stack, { includeRetired: true })` — the artifact-ingestion door and `os migrate meta` — no longer emits or applies this rewrite. The default load posture (`includeRetired: false`) is unchanged: the conversion was already skipped there.
15+
- `os migrate meta --from 16` no longer lists it, and a `required: true` field crosses 16 → 17 carrying its write contract and nothing else.
16+
- Boot no longer warns about it, so an artifact whose only conversion was this one now boots with that warning gone.
17+
- Nothing is authored differently and nothing is refused that was accepted before. `required` and `storage.notNull` both remain authorable and both keep their ADR-0113 meanings. A column is NOT NULL because its author wrote `storage: { notNull: true }`, and for no other reason.
18+
19+
**No migration is owed to anyone** (maintainer ruling, 2026-09-08, decision batch #85, option A). Genuinely pre-ADR-0113 artifacts are not measured to exist, existing columns are left exactly as they are, and an app that wants NOT NULL columns declares `storage.notNull` deliberately — which is what the app that reported this had already done.
20+
21+
The protocol-17 ledger entry and the generated upgrade guide now say this in the other direction too, and the falsified sentence in `sql-driver.ts` — "sources authored before protocol 17 carry `storage.notNull` explicitly via the `field-required-notnull-explicit` conversion, so their columns come out exactly as they always did" — is corrected where it stood.
22+
23+
Two sentences in `@objectstack/driver-sql` that this withdrawal falsifies are corrected with it, and no drift behaviour changes. The `relax_not_null` finding — raised when a column is NOT NULL and the metadata declares no `storage` constraint — used to prescribe "(pre-protocol-17 sources: `os migrate meta` stamps it for every previously-required field)"; it now says the constraint has to be declared by its author, because nothing supplies it any more. The comment beside it, which closed with "`os migrate meta` ratifies it whenever the source is next migrated", says so too. The deliberate SILENCE for a `required: true` field whose column is already NOT NULL is unchanged — this corrects the sentences, never the finding.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`PLATFORM_TOOLS_BY_PACKAGE['service-ai-studio']` lists the five tools the cloud AI runtime registers that it had been omitting: `get_authoring_rules`, `load_tools`, `open_record`, `test_flow` and `toggle_flow`. Added in the list's existing alphabetical order; nothing else in the registry moves.
6+
7+
The omission was not cosmetic. `PLATFORM_PROVIDED_TOOL_NAMES` is the load-bearing half of `skill.tools[]` reference integrity under ADR-0109 — the default third-party authoring path declares no tool records at all, so a `skill.tools[]` entry resolves against this registry or against the materialised `action_<name>` family and against nothing else. While these five were absent, a skill naming any of them was reported by `validate` / `lint` as a **fictional** tool reference (`ai-skill-tool-unresolved`), which is precisely the failure the registry was created to end. Five previously-refused references are now accepted; a name registered by nobody is still refused.
8+
9+
The module's own maintenance contract already said why an omission is worse than no registry at all — "an out-of-date registry is worse than no registry, because consumers now trust it" — and a second consumer had already paid for it: `@objectstack/mcp` gives a listed name `openWorldHint: false`, and the Studio's tool-step labels read the same set.
10+
11+
This is the data half only. Making the owning package's conformance test **derive** the union from what `plugin.ts` actually registers, instead of restating it, is tracked separately in the cloud repository; re-copying the list correctly resets the clock rather than stopping it.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
The two operator run-lifecycle verbs get a door: `POST /automation/:name/runs/:runId/cancel` and `POST /automation/:name/runs/:runId/restore-suspension`, both gated on the platform operator.
6+
7+
`AutomationEngine` has carried `cancelRun` (ADR-0044) and `restoreConsumedSuspension` (#13909) for as long as either has existed, and neither was reachable by an operator: no REST route, no CLI command, and — until `IAutomationService` declared them — no way for a host holding only the service contract to call them either. The gap mattered most for the repair verb, which has no in-process caller by design: it is meant to be asked for by a person, and there was no way to ask. Maintainer ruling, 2026-09-05 (option A): both are platform-operator verbs, and listing and acting go through `sys_automation_run`, never engine memory.
8+
9+
- **The gate is the ADR-0095 D2/D3 posture rung** (`posture === 'PLATFORM_ADMIN'`), required **unconditionally**, with the usual `isSystem` bypass so `plugin-approvals`' in-process revise-window recall is untouched. It is deliberately not the `positions[]` entry spelling the built-in: `sys_user_position` is `apiEnabled` with unconstrained values, so a tenant can mint that row. It is also deliberately not posture-conditional the way the ADR-0126 §5 activation gate is — that gate falls open under `single` because a `manage_metadata` tier still stands in front of it, and this door has no tier in front of it, so the same conditionality would open an operator verb to any authenticated caller on every single-organization deployment. Which routes is one predicate, read by the gate and by both route arms so they cannot drift.
10+
- **Refusing fail-closed on an absent member is the door's own job.** Both verbs are optional members of the contract, as 13 of its 15 are. A service that does not declare one answers **501 `NOT_IMPLEMENTED`** naming the member — never a 200 carrying a lifecycle verdict for a verb that was never dispatched, and never the `{ handled: false }` fall-through that renders as a 404 with a discovery hint for a route discovery does not list.
11+
- **Refusals are refusals.** The restore door maps the implementation's refusal code onto the statuses this same door already answers those conditions with on `resume``RUN_NOT_FOUND` 404, `STORE_UNAVAILABLE` 503, and the run-state conflicts 409. The contract types that code as `refusal?: string`, a covariant widening of the engine's closed eight-member union, so the mapping is a **non-exhaustive string switch by construction**: an unrecognised code — or a refusal carrying none — answers **500**, not one of the 409s, which would claim a diagnosis the door did not make. The vocabulary is neither narrowed nor extended at the call site. The code rides `details.refusal`, never `details.code`, so `error.code` stays inside the ADR-0112 closed catalog.
12+
- **`requestedBy` comes from the authenticated caller, never the wire.** The repair verb's trace records who asked and why; a wire-settable `requestedBy` would let one operator write another's name into the record of who re-armed a terminally-failed run. The body envelope is closed to `{ reason? }` and refuses the key by name, so a caller who tries gets a loud refusal instead of the silent impression that it took.
13+
- **No once-only side effect keys off `cancelRun`'s return, and the wire says why.** The engine has no cancel-side compare-and-set, so two overlapping cancels each answer `true` and each record the terminal log. This door fires no notification, writes no audit entry and announces no kernel event; the `true` answer carries a notice stating the non-exclusivity so a caller does not build that side effect one tier up. The `false` answer carries the other half: the contract's idempotent-success reading **and** the unreadable-store reading, which land on the same `false` and which nothing above the engine can tell apart.
14+
15+
⛔ No lister ships here. A lister backed by the engine's in-memory journal answers zero in any process that did not itself strand the run, and a confident zero is the failure this class is about; a correct one is a query over `sys_automation_run` terminal rows and is its own card. ⛔ No CLI command either — the ruling declines one for want of pull.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os package publish` now decides what a manifest id is by parsing it through `PackageSchema.manifestId` — the schema for the very column it publishes into — instead of testing it against a hand-copied look-alike.
6+
7+
The command carried its own rule (`MANIFEST_ID_RE`, a case-insensitive "starts alphanumeric, then any of a-z 0-9 dot underscore hyphen, up to 255 chars"), which is looser than the declared contract on every axis. The local preflight therefore **admitted what the control plane refuses**: a single segment (`crm`), an underscore (`com.acme.repair_desk`), upper case (`COM.ACME.CRM`), a digit-first segment (`9foo.bar`), an empty segment (`com..acme`) and a trailing dot (`com.acme.`). The preflight passed, the request went out, and the server answered `400`. Its error text, when it did fire, named a contract (`a-z0-9._-`) that does not exist — so a user who followed the message walked into a second refusal.
8+
9+
- **One rule, both paths.** `MANIFEST_ID_RE` is deleted. The explicit `--manifest-id` / `objectstack.manifest.json` path and the derive path (`deriveManifestId`, which adopts `artifact.manifest.id`) now ask the same imported schema. They previously disagreed with each other as well as with the declaration: the derive path additionally required a dot, so a bare `crm` was blocked there and accepted on the explicit path. That extra condition is gone because the schema subsumes it — its pattern requires at least two segments.
10+
- **The refusal text is quoted from the schema**, from its own `invalid_format` issue plus its `.describe()`, so it can no longer drift from the rule it describes.
11+
- **A derived id the schema rejects is refused, not rewritten.** `slugify` has no letter-first rule, so an app named `2024 App` derives `local.2024-app` — digit-first, and rejected. That is now refused before any network call, with a message naming where the id came from and how to set one (`--manifest-id`, `manifestId` in `objectstack.manifest.json`, or `manifest.id`). It is deliberately not normalised into some other id: `manifestId` is immutable once published, and minting a different permanent global identifier than the inputs imply is worse than saying what is wrong.
12+
13+
Publishing is unaffected for every id the control plane accepts — a legal reverse-domain id passes both paths with unchanged bytes. What changes is that the ids the server was going to reject are now refused locally, with the real rule in the message.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `FlowSchema` refuses a region node whose id is already declared elsewhere in the flow — one node-id space across the top-level `nodes[]` and every region body (#16134)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is renamed, retired or re-typed: `nodes[].id` keeps its name, its type and its describe at every depth, and every flow whose node ids are unique across the whole flow parses byte-identically. The only newly refused shape is a region node (`loop.config.body`, `try_catch.config.try` / `.catch`, `parallel.config.branches[]`, at any depth the parse walks — nesting up to `MAX_REGION_DEPTH` = 32) carrying an id that a top-level node or a node in another region already declares — a collision, not a spelling — and its remedy is to rename one of the two (and re-point the edges that meant it), which is authoring intent no `objectstack migrate meta` rewrite can choose for the author. The census over this repository at `83863b2df` (AST scan of `packages/**` and `examples/**`: 972 outermost literal `nodes[]` arrays including tests, 66 excluding; 102 region arrays / 86 region nodes with a literal id, 15 / 11 excluding tests; a planted region-reuses-top-level-id control reads 1 at its planted line) found zero cross-region or region-vs-top-level collisions, so there is no in-repo file to name. -->
8+
9+
**BREAKING** accept-set narrowing on `FlowSchema` — a flow has **one node-id
10+
space**. A node inside an ADR-0031 region body (`loop.config.body`,
11+
`try_catch.config.try` / `.catch`, each `parallel.config.branches[]`, nested to
12+
any depth the parse walks — up to `MAX_REGION_DEPTH` = 32 levels) whose `id` is
13+
already declared by a top-level node, or by a node in
14+
any other region of the same flow, is now **refused at parse time** — by
15+
`FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that validates a
16+
flow through the schema (`objectstack validate`, the runtime publish gate, a
17+
stack's `flows[]`) — where it used to parse on green. Shipped as `minor` under
18+
the repo's launch-window convention for breaking changes. Maintainer ruling
19+
(director seat, decision batch #61, 2026-09-07, 「同意」): ADR-0031's
20+
"self-contained single-entry / single-exit sub-graph" describes control flow and
21+
variable scope, not id reuse; every reader that flattens a flow may key on the
22+
bare id. The ADR gains one sentence saying so in this same change.
23+
24+
Before this change uniqueness was enforced **inside** each array — the
25+
top-level `nodes[]` by `FlowSchema` (#15713) and each region body by
26+
`analyzeRegion` at `registerFlow()` — and never **across** them: a loop-body
27+
node could carry the same `id` as a top-level node, or as a node in a sibling
28+
branch, and both rules stayed green. Every edge's `source` / `target` names a
29+
node by id, and the designer canvas, the BPMN export, a flow diff and a
30+
checkpoint's `completedNodeIds` all key on the bare id, so such a collision was
31+
silently wrong wherever a flow is flattened.
32+
33+
**What changes** (`packages/spec/src/automation/flow.zod.ts`): the existing
34+
`superRefine` pass over `nodes[]` now walks every graph the parse reaches via
35+
`collectFlowGraphs` — the top-level graph first, then each region in document
36+
order, depth first, down to `MAX_REGION_DEPTH` (32) — keeping one map of first
37+
declarations. A later occurrence
38+
raises the same single `custom` issue as before, anchored at the later node's
39+
own `id` (inside the region, e.g. `nodes.1.config.body.nodes.0.id`) and naming
40+
both locations — a top-level index (`nodes[1]`) or a region path
41+
(`loop 'sweep' body → nodes[0]`):
42+
43+
```text
44+
✗ nodes.1.config.body.nodes.0.id: Duplicate node id `start` — `loop 'n' body → nodes[0]` reuses the id already declared by `nodes[0]`; every node id in a flow must be unique. Rename one of them: …
45+
```
46+
47+
One refusal, one message shape, at every depth the parse walks: within
48+
`MAX_REGION_DEPTH` an author never sees two issues for one collision. A region
49+
nested beyond that ceiling is left raw by the parse and stays
50+
`validateControlFlow`'s, in its own line — there `analyzeRegion`'s
51+
`duplicate node id 'X'` is the only refusal of a within-region duplicate (a
52+
cross-region collision past the ceiling is not judged), and the same line
53+
guards `bpmn-mapping`'s raw-region caller, so it is kept on purpose.
54+
`collectFlowGraphs` gains a `path` field beside `scope` — the same location as
55+
a key path — so the issue can be anchored where the author wrote the node; it
56+
also now skips a non-object element in a region its own schema refused (such a
57+
region is left raw for `validateControlFlow` to name), where it used to throw a
58+
`TypeError` from inside that validator.
59+
60+
**What does NOT change:** `nodes[].id` keeps its name, type and describe; the
61+
open node-type vocabulary (ADR-0018), the region rules (edge integrity,
62+
single-entry / single-exit, acyclicity) and every other refusal are untouched;
63+
a flow whose node ids are unique across the whole flow parses exactly as
64+
before, region nodes included, in authored order.
65+
66+
The shape that is refused, and what the author does about it — the region node
67+
renamed, and any region edge that meant it re-pointed:
68+
69+
```ts
70+
// before — parsed on green, `start` declared twice (top level + loop body)
71+
nodes: [
72+
{ id: 'start', type: 'start', label: 'Start' },
73+
{ id: 'sweep', type: 'loop', label: 'Sweep', config: { collection: '{items}', body: {
74+
nodes: [{ id: 'start', type: 'assignment', label: 'First step' }],
75+
} } },
76+
{ id: 'end', type: 'end', label: 'End' },
77+
]
78+
79+
// after — refused at parse (nodes.1.config.body.nodes.0.id: Duplicate node id `start` …);
80+
// rename the region node and point the region's edges that meant it at the new id:
81+
nodes: [
82+
{ id: 'start', type: 'start', label: 'Start' },
83+
{ id: 'sweep', type: 'loop', label: 'Sweep', config: { collection: '{items}', body: {
84+
nodes: [{ id: 'sweep_first', type: 'assignment', label: 'First step' }],
85+
} } },
86+
{ id: 'end', type: 'end', label: 'End' },
87+
]
88+
```
89+
90+
**Remedy.** Rename the later node to an id nothing else in that flow carries —
91+
no top-level node, no node in any region — then re-point at the new id the
92+
edges whose `source` / `target` meant it; nothing else in the flow needs to
93+
move. The census over this repository found no flow to migrate, so this is a
94+
release note, not a migration: no shipped example, fixture or seed in
95+
`packages/**` or `examples/**` declares a region node id that collides with a
96+
top-level or another region's node id.

0 commit comments

Comments
 (0)