Skip to content

Commit 645b14e

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-13533-system-write-sharing-materialization
Resolves the single CONFLICT (content) in content/docs/permissions/system-context.mdx (the os-regen driver flags this file as MIXED — generated anchors plus hand-written prose — so it text-merges instead of taking one side whole). Row 37 (share-link policy `enabled` check, isSystem) diverged: this branch's copy already carried the #14033 prose ("resolution is not bypassed since #14033 ... a link minted this way does not resolve until the block is enabled"), added when 13bf05d landed on both branches. main's copy had reverted to the pre-#14033 wording via an unrelated commit (8ce628a, "store a serializable manifest projection in the package registry (#14499)") whose own docs-regen step appears to have run against a stale pre-#14033 checkout of this file. Kept this branch's row 37 text: packages/plugins/plugin-sharing/src/ share-link-service.ts:652-697 (the `[#14033]` docblock and the `if (!policy.enabled)` redemption-time check it documents, citing the maintainer's 2026-09-01 ruling and this exact ledger row by number) confirms it is what the current source does, not the reverted main copy. Row 36 differed only in its generated anchor line number (1088 vs 1077) — anchor numbers are recomputed by `pnpm gen:system-context-census` below, so either side was equivalent; kept this branch's. Anchors re-derived on the merged tree with `pnpm gen:system-context-census` in a follow-up commit, per the os-regen driver's documented recovery for this file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
2 parents e9b612a + 4d0d944 commit 645b14e

146 files changed

Lines changed: 12455 additions & 532 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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): an action's `description` and its parameter dialog now honour the translation bundle
6+
7+
`translateAction` overlaid only `label`, `confirmText`, `successMessage` and
8+
`resultDialog`. The keys for the rest were already there — the translation
9+
schema declares `_actions.ACTION.description` and
10+
`_actions.ACTION.params.PARAM.{label, helpText, placeholder, options}`, and the
11+
translation linter validates both, reporting a parameter key the action does not
12+
declare with a did-you-mean naming the ones it does. So the keys parsed, they
13+
linted, and they resolved to nothing: a translated deployment rendered a
14+
translated action button that opened an untranslated form, because an action's
15+
`description` is the explanatory line under the dialog title and its
16+
parameters' `label` / `helpText` / `placeholder` / option labels are the rest of
17+
that dialog.
18+
19+
They are applied now, wherever the action is served — the REST metadata read,
20+
OpenAPI, MCP — and through `globalActions` for an action that belongs to no
21+
object, the same object-scoped-first order every other action key already used.
22+
23+
Parameters are matched by `name`, falling back to `field` for a field-backed
24+
parameter that names no key of its own: the same rule the linter collects
25+
parameters by, so a key the linter accepts is a key the resolver finds. Option
26+
labels are matched on the stored option `value`, since the authored side is an
27+
array of options while the translation side is a `value` to label map.
28+
29+
Nothing changes for a bundle that carries none of these keys: the authored text
30+
is kept, the parameter array keeps its identity, and a bundle key naming a
31+
parameter the action does not declare is ignored rather than invented into the
32+
dialog. No schema, no validator and no accepted shape moves — every key applied
33+
here was already declared and already validated.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/metadata": patch
3+
"@objectstack/core": minor
4+
"@objectstack/objectql": patch
5+
"@objectstack/runtime": patch
6+
---
7+
8+
fix(metadata): register a `packages[]` artifact per package at the metadata door so every object has one owner across every door (#14599)
9+
10+
A release artifact carrying `packages[]` (ADR-0130 D4) was read at the metadata
11+
door as if it carried one package: `MetadataPlugin._parseAndRegisterArtifact`
12+
iterated the **flattened top level** and stamped every item with the artifact's
13+
own `manifest.id`. For an artifact composed with `composeStacks(…, { manifest:
14+
'preserve' })` that id is one arbitrary member's — `selectManifest`'s `'last'`
15+
pick — so a two-package artifact registered the **module's** object under the
16+
**App** package's identity, while the ObjectQL load path, reading the same
17+
artifact's `packages[]`, owned it under the module's.
18+
19+
The platform then held two answers to "who owns this object", and which one a
20+
consumer saw depended on the door it went through. Measured on a real boot of
21+
`examples/app-multi-package`:
22+
23+
- `GET /api/v1/meta/object` served `crm_order` **twice** — the list merge keys
24+
slots by `${packageId}${name}`, so the two differently-attributed copies
25+
landed in two slots;
26+
- `GET /api/v1/meta/object?package=<the App package>` returned the **module's**
27+
object, because the App-stamped copy was re-ingested into the registry as that
28+
package's contribution;
29+
- the layers door named the App package while the item door and
30+
`GET /api/v1/packages` named the module;
31+
- Studio's Data pillar for the App package listed the module's object — ADR-0130
32+
Consequences §1.3a ("Studio's scope is the package") did not hold.
33+
34+
**The door now reads both shapes, and attributes every item to the body it was
35+
found in.** `packages` present → each assembled package body's collections are
36+
registered stamped with **that body's** id; `packages` absent → the single
37+
`manifest` branch runs exactly as before (D7). The owner is read off the body an
38+
item was found in — never reverse-derived by matching a top-level item's name
39+
against a name-to-package index, which would be the second metadata-identity
40+
resolution path #14512's triage rejected by name.
41+
42+
**Ordering and the entry gate are reused, not re-derived (D5).** The door calls
43+
the same `resolveArtifactPackageOrder` the ObjectQL load path calls, so the two
44+
readers of one `packages[]` cannot disagree about the registration order **or**
45+
about which artifacts are loadable at all.
46+
47+
⚠️ **`resolveArtifactPackageOrder` / `artifactPackageId` moved to
48+
`@objectstack/core`** — hence the `minor` there. They were in
49+
`@objectstack/objectql`, which **depends on** `@objectstack/metadata`, so the
50+
metadata door could not import them from where they lived; `@objectstack/core`
51+
already owns `resolvePluginOrder` and is already a dependency of both readers,
52+
so hosting them there adds **no edge** to the package graph. `@objectstack/objectql`
53+
re-exports both under their existing names — its published surface is unchanged,
54+
which is why it is graded `patch`. `@objectstack/runtime` is `patch` for the
55+
dispatcher error vocabulary's `file:` anchors, repointed at the new path.
56+
57+
**Single-package artifacts are byte-for-byte unaffected (D7)**, measured rather
58+
than asserted: the whole `manager.register` sequence for a single-`manifest`
59+
artifact — every call, in order, with the id and version each item was stamped
60+
with — is pinned as a literal in
61+
`packages/metadata/src/plugin-artifact-packages-attribution.test.ts` and was
62+
recorded identically on both legs of the ablation. A real boot of
63+
`examples/app-todo` answers every door identically before and after.
64+
65+
**Nothing a booted instance can see today disappears.** Every live
66+
`ARTIFACT_FIELD_TO_TYPE` key is a member of `AssembledPackageBodySchema`
67+
(measured, not assumed), so iterating bodies loses no collection; and because
68+
`packages` composes by `concat`, an artifact whose top level carries a
69+
definition no package body repeats keeps it — registered once, attributed to the
70+
artifact's own identity, and logged, because it means the artifact's two halves
71+
disagree about what it ships.
72+
73+
⛔ The **producer** half is untouched: `composeStacks` and `os build` keep
74+
emitting the flattened top level alongside `packages[]`. Whether they should is
75+
#14512's decision, not this door's.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
feat(plugin-auth): auth mail follows the caller's `Accept-Language`, deployment default second (#14319)
6+
7+
Request-triggered auth email — signup verification, password reset, magic link,
8+
and the change-email notice — now picks its `sys_email_template` row from the
9+
requesting caller's `Accept-Language`, falling back to the deployment default
10+
(`localization.locale`, then `i18n.defaultLocale`) and finally to
11+
`EmailService`'s documented `en-US`.
12+
13+
The motivating case is the one no deployment default can answer: at cloud
14+
self-service signup there is no workspace yet, so nothing on the server
15+
represents that person's language — a Chinese browser reached a Chinese signup
16+
screen and received an English verification email.
17+
18+
The header is parsed by the platform's existing `preferredLocaleFromHeader`,
19+
the same function REST uses for metadata translation and the runtime dispatcher
20+
uses for `ExecutionContext.requestLocale`, so the mail cannot disagree with the
21+
screen that triggered it. A requested locale takes effect only when it names one
22+
of `AUTH_EMAIL_TEMPLATE_LOCALES` (`en-US`, `zh-CN`, `ja-JP`, `es-ES`); anything
23+
else falls through rather than naming a row that does not exist.
24+
25+
Two deliberate exclusions. **Invitations keep the deployment default**:
26+
better-auth hands that callback a request too, but it is the *inviter's*, and
27+
stamping their browser language onto the invitee's mail would reproduce this
28+
same defect one seat over. **Per-user language stays deferred**`sys_user`
29+
grows no locale column here.
30+
31+
This ships as `minor` because it changes which template row an existing
32+
deployment sends: a workspace whose users' browsers ask for a different language
33+
than the workspace declares will now send in the browser's language.
34+
35+
**Ruling:** maintainer, 2026-09-02, superseding the 2026-08-13 ruling that had
36+
rejected `Accept-Language` outright. Both are recorded, with the older one
37+
marked superseded, on `AuthManager.setDefaultEmailLocale`.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): name the terminally-failed-but-repairable run on `AutomationResult.status``'stranded'` (#14384, contract half of #13937)
6+
7+
`AutomationResult.status` (`contracts/automation-service.ts`) gains a fourth
8+
member beside `'completed' | 'paused' | 'failed'`: **`'stranded'`** — the run
9+
whose resume CONSUMED its suspension and then had a downstream node throw, so
10+
the run is recorded as failed and can be re-armed only by an explicit operator
11+
verb (#13909's condition; the #13937 shape-4 ruling, maintainer 2026-09-01
12+
「命名同批定」). The wire mirror `TriggerFlowResponseSchema.data.status`
13+
(`api/automation-api.zod.ts`) carries the same four, and a pin test binds the
14+
two at the type level and the value level.
15+
16+
Additive: no existing literal changes meaning, `'failed'` still says "the run
17+
ran and was rejected", and no engine, route or client behaviour moves in this
18+
change — the engine begins stamping `'stranded'` when #13937's services half
19+
(the operator re-arm verb) lands. A consumer that switches exhaustively over
20+
`status` needs a `'stranded'` arm; the measured count of such switches in this
21+
repo is zero. plugin-approvals' report-only `StrandedRunState`
22+
(`'missing' | 'failed'`) is deliberately not promoted (same ruling).

.changeset/chilled-eels-shave.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
A `state_machine` rule's refusal now carries `constraint` and `value` alongside an **author-written** `message`, not only alongside the built-in one (#14311).
6+
7+
`checkStateMachine` emitted the full field-error envelope — `field`, `code`, `message`, `label`, `constraint`, `value` — when the rule left its message empty, but dropped `constraint` and `value` the moment the rule declared one. Since `ValidationRuleSchema` **requires** `message` on every rule, the machine-readable half was in practice reachable only by declaring `message: ''`: every normally-authored state machine refused writes with no way for a client to learn *which* states are legal.
8+
9+
A create form that wants to offer exactly the declared `initialStates`, or a detail page that wants to grey out illegal transitions, had to parse the author's prose or keep a second copy of the state machine.
10+
11+
Now both paths emit the same envelope:
12+
13+
- insert — `constraint: { allowed: 'planned' }`, `value: 'active'`, `code: 'invalid_initial_state'`
14+
- update — `constraint: { from: 'draft', to: 'approved' }`, `code: 'invalid_transition'`
15+
16+
The author still owns the wording; only the facts beside it are restored. Nothing about which writes are refused changes, and `constraint` / `value` are already declared on `FieldValidationError` (mirroring `FieldErrorSchema`), so no consumer contract widens — REST ships the same `400 VALIDATION_FAILED` envelope with the fields it always declared.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os generate` stops naming field types that do not exist (#13871)
6+
7+
`packages/cli/src/commands/generate.ts` carried three hand-authored field-type
8+
vocabularies — `FIELD_TYPE_MAP` (`os generate types`), `FIELD_TYPE_SQL_MAP`
9+
(`os generate migration --format sql`) and the `switch (fType)` in the
10+
typescript migration generator — and none of the three had ever been checked
11+
against the `FieldType` enum it claims to describe. Between them they named six
12+
types the platform has never had: `slug`, `ip_address`, `encrypted`, `integer`,
13+
`uuid`, and `geo_point`.
14+
15+
They are not leftovers of retired types. `git log -S` over the whole reachable
16+
history of `packages/spec/src/data/field.zod.ts` returns zero commits for every
17+
one of those tokens — they were invented in the CLI and mirrored table to table
18+
inside this one file.
19+
20+
Through every supported authoring path the arms were unreachable: `os init`
21+
scaffolds `export default defineStack({ … })`, `define*` is a strict
22+
`Schema.parse`, and a field typed `slug` is refused while the config module is
23+
evaluated — before the generator runs a line. The one input class that could
24+
reach them is a config that parses nothing (a plain-object default export, or
25+
`defineStack(x, { strict: false })`), and for that class the generators were
26+
emitting bespoke columns for types no runtime can serve. A vocabulary is a claim
27+
about what the platform accepts, so the visible cost of keeping them was that
28+
anyone — or any model — reading this file to learn the field types learned six
29+
that do not exist.
30+
31+
Every ghost is removed rather than re-spelled. None of the six was a
32+
misspelling of a real member with a fix to apply: `number` already had its own
33+
entry and arm, so `integer` had nothing to correct to; `address` is a structured
34+
postal address, not an IP; and the concepts that later arrived under other names
35+
(`secret`, `location`) have no entry in these tables at all, which is a separate
36+
coverage question rather than a spelling one.
37+
38+
Behaviour is unchanged for every config the platform accepts. For a config that
39+
bypasses validation, a field typed with one of the six now falls to the same
40+
default any unknown type gets — `table.text` / `TEXT` / `unknown` — instead of a
41+
bespoke column.
42+
43+
`generate-field-type-vocabulary.pin.test.ts` now reads all three vocabularies
44+
out of the source and fails on any key or case label that is not a `FieldType`
45+
member, so the class cannot reopen. The pin is forward-only: real members with
46+
no entry still fall through to the deliberate default, which it does not
47+
prejudge.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/runtime": patch
4+
---
5+
6+
refactor(objectql,runtime): give the standalone-action owner-key ladder one spelling (#14422)
7+
8+
`action.objectName` -> `action.object` -> the object-less `'global'` key decides
9+
which engine key a standalone `action` declaration is filed under. It was
10+
written out three times — `standaloneActionOwnerKey` in
11+
`packages/objectql/src/action-governance.ts`, `standaloneActionObjectName` in
12+
`packages/runtime/src/action-execution.ts`, and a private
13+
`ObjectQLPlugin.actionObjectKey` — and the only thing holding the three equal
14+
was a sentence in each docblock saying it must stay in lockstep with the
15+
others. #14123 was already the bill for that shape: two readers of "where does
16+
this declaration live" answering from different code.
17+
18+
All three now resolve to the one implementation. The plugin calls
19+
`standaloneActionOwnerKey` directly (same package, four call sites, not the one
20+
the card estimated); the runtime re-exports it in the ADR-0110 block that
21+
already exists in that file for exactly this purpose, alongside
22+
`GLOBAL_ACTION_OBJECT_KEY`, `isObjectLessActionKey` and the rest. No behaviour
23+
moves: the three ladders were measured equivalent across a twelve-row truth
24+
table before the change.
25+
26+
**The divergence this removes was real, not hypothetical.** The plugin's copy
27+
terminated on a bare `'global'` string literal while the other two return the
28+
shared `GLOBAL_ACTION_OBJECT_KEY` constant. The constant is `'global'` today, so
29+
the three agreed and nothing was broken — but the plugin copy was the one that
30+
would have parted from the others in silence the day that constant moved, and
31+
no test in the repo would have caught it. The same literal in the plugin's
32+
`isArtifactShippedAction` reader is converged to the constant with it.
33+
34+
**`_deps`: kept, as a delegating alias — not dropped.** The engine helper is
35+
`standaloneActionOwnerKey(action)` and the runtime's name is
36+
`standaloneActionObjectName(_deps, action)`. `_deps` was already unused, but
37+
dropping it would move an EXPORTED signature to save two characters at the two
38+
in-repo call sites, both of which live in `action-execution.ts` itself. The
39+
alias keeps its arity and its meaning, so `ownsRoute` and any out-of-repo
40+
importer compile and behave exactly as before; its body is now
41+
`return standaloneActionOwnerKey(action);` and nothing else.
42+
43+
**Levels, and the instrument.** `@objectstack/objectql` is `minor` because
44+
`standaloneActionOwnerKey` had to be added to its published entry
45+
(`src/index.ts`) for the runtime to import it at all — measured in the built
46+
`packages/objectql/dist/index.d.ts`, where the name is now both declared and
47+
exported. `@objectstack/runtime` is `patch`: `action-execution.ts` is not
48+
re-exported from `packages/runtime/src/index.ts` and the package publishes only
49+
`.`, so the new re-export does not reach the published entry — measured as zero
50+
occurrences of `standaloneActionOwnerKey`, `standaloneActionObjectName` and
51+
`GLOBAL_ACTION_OBJECT_KEY` in the built `packages/runtime/dist/index.d.ts`,
52+
against a positive control of 32 for `HttpDispatcher`.
53+
54+
The docblocks that promised lockstep are replaced by welds that enforce it —
55+
`action-owner-key-single-source.test.ts` in each package — because a docblock
56+
is not a check. Each is scoped to its own package's source, so neither becomes
57+
a cross-package test input.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `DataEvent` names the organization the record belongs to, so a tenant-scoped consumer can tell whose event it is
6+
7+
The realtime `DataEvent` payload (`@objectstack/spec/api`, the body of every
8+
`data.record.created` / `data.record.updated` / `data.record.deleted` event)
9+
gains an optional `organizationId`: the organization the record belongs to.
10+
Until now the event carried the object name, the record id and the row body,
11+
and nothing that named the tenant — so a consumer that fans events out per
12+
organization (a webhook subscription, a per-organization realtime subscriber)
13+
had no term to discriminate on short of reading the row body, which is absent
14+
on delete events and is not the consumer's to read.
15+
16+
What a consumer may assume:
17+
18+
- **Present** — exactly that organization, never a guess: the organization the
19+
record belongs to, not the caller's active organization standing in for it.
20+
- **Absent** — the record belongs to no organization. That is every event on a
21+
`single`-posture deployment (no organization wall, nothing stamps the
22+
column) and an organization-less, environment-wide row under a walled
23+
posture. Read it as "not behind any organization wall", never as "unknown,
24+
look it up".
25+
26+
Declared = enforced: the key is optional and nothing else. No default
27+
fabricates a tenant; `null` and the empty string are refused with a located
28+
issue, so "no organization" has exactly one spelling — the key is absent.
29+
30+
Additive and shape-preserving: every event that parsed before parses
31+
identically, and no producer emits the key yet — the ObjectQL engine's publish
32+
site is a separate change that follows this contract. The bulk
33+
`BulkDataEvent` (`data.records.*`) is deliberately untouched: a predicate
34+
write's affected set is its own contract with its own tenant question.

0 commit comments

Comments
 (0)