Skip to content

Commit 42fa4f1

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-25078-export-value-context
Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude <noreply@anthropic.com>
2 parents bf8244c + 06900c9 commit 42fa4f1

203 files changed

Lines changed: 9075 additions & 2891 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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
'@objectstack/driver-turso': minor
4+
'@objectstack/driver-memory': minor
5+
'@objectstack/objectql': minor
6+
'@objectstack/metadata-protocol': minor
7+
'@objectstack/cli': minor
8+
---
9+
10+
feat(driver-sql,driver-turso,driver-memory,objectql,metadata-protocol,cli): an organization-scoped unique index keys the plain organization column, and an index an earlier release built with `COALESCE(organization_id, '__global__')` is read as the same index on a certified database
11+
12+
Clause-②: yes (widening)
13+
Metadata-protocol: semantic
14+
15+
18.x step 7b-1 + 7b-2 (ADR-0131 D1: the tenant column is NOT NULL, so the NULL-organization bucket of ADR-0120 D3 has no rows left to hold).
16+
17+
- **New DDL is plain.** A field-level `unique: true` / `'organization'` on a tenant-scoped object, and a declared index with `unique: 'organization'`, now build `UNIQUE (organization_id, …)` instead of `UNIQUE (COALESCE(organization_id, '__global__'), …)`. Index names do not change. The same holds for the Turso remote face, the MySQL hash shadow (its generated column now hashes the plain column), the in-memory driver's unique enforcement, `os generate migration` (which now emits the organization composite it used to list as NOT EMITTED), and the `sys_setting` row-identity index (`COALESCE(user_id, '')` stays).
18+
- **Existing indexes stay as they are.** Nothing is renamed, and no declared organization composite is dropped or rebuilt by the boot, `os migrate plan` or `os migrate apply`; on SQLite the ownership ceremony's NOT NULL step rebuilds the tables it tightens, and their composites come back plain, enforcing the same constraint. One index is re-keyed: the `sys_setting` row-identity index, which the metadata protocol rebuilds on every boot, keys the plain `organization_id` from the next boot on (probe-first; a pure relaxation, so it cannot fail on existing rows). The schema differ reads `COALESCE(<tenant>, …)` and the plain tenant column as one key part, in both directions, where the column is physically `NOT NULL` or the database is certified (its organization-ownership birth marker, or the ownership ceremony's marker at the current shape generation). So `os migrate plan` and the boot report no `recreate_index` for those indexes. On an uncertified database with a nullable tenant column the two forms stay different constraints: the finding is reported, and it names `os migrate organization-ownership` as its remedy.
19+
- **What widens.** Where a tenant column is still nullable (every table of a database 18.0.0 created, an uncertified 17.x / 18.0 database, a rotation shard, a Turso remote table, a table the ceremony reported), a unique index created from now on does not constrain rows whose organization is NULL, exactly like a NULL in any other key part; in the in-memory driver a record with no organization is exempt from an organization-scoped unique. The unique duplicate pre-flight no longer reports groups of rows with a NULL key part, since the index never refuses them.
20+
- **Who sees it.** A fresh install and every ceremonied table whose tenant column is NOT NULL see nothing. A database 18.0.0 created from empty is certified by its birth marker yet keeps nullable tenant columns: its existing indexes are kept unchanged, and a new organization unique declared on one of its tables constrains only rows that carry an organization.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth)!: the last-admin guard and the default-organization bootstrap count only the Default Organization's `admin_full_access` grant — an organization-less grant is no administrator
6+
7+
Clause-②: yes (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The rows this reads differently exist only on a database the ownership ceremony has not certified, or in a composition that registers no organization object. -->
11+
12+
**BREAKING** for a host that holds an `admin_full_access` grant row with no `organization_id` (`NULL`, absent or blank). Since the resolver stopped reading such a row as platform-admin standing (ADR-0131 D1/D5), the two plugin-auth readers that still counted it now read it the same way.
13+
14+
- **`registerLastAdminGuard` (the last-administrator guard).** FROM: an organization-less `admin_full_access` grant counted as a platform administrator under `single`, beside the Default Organization's. TO: only the Default Organization's grant counts, and only under `single`; with no nameable Default Organization (a composition that registers no organization object among them) no grant counts. The configured administrator list (`OS_PLATFORM_OWNER_EMAIL`, verified accounts) and organization owners and admins count as before.
15+
- The guard now **refuses** a write it used to permit: ending the standing of the last administrator the resolver recognises while an organization-less holder exists. Counting that holder let the deployment's only platform administrator be banned, deleted or downgraded.
16+
- It now **permits** a write it used to refuse: banning, deleting or revoking an organization-less holder who is nobody's administrator.
17+
- Under a walled posture, a switched-off or dangling organization-less grant is no longer "emptied environment" evidence, and the refusal no longer carries its walled remedy sentence (no grant is evidence there).
18+
- **`ensureDefaultOrganization` (the bootstrap's grant anchor).** It reads only the Default Organization's `admin_full_access` grant, and no grant when no Default Organization is nameable. An organization-less grant holder is never bound as the Default Organization's owner. The configured owner is read first, as before.
19+
20+
**Who sees it.** No served composition: a fresh install, a certified 18.0.0 database and a ceremonied database hold no organization-less grant, and the engine derives or refuses one wherever the organization object is registered. What changes is a host that embeds the guard on an engine with no organization object (for example on driver-memory), or runs plugin-auth outside `os serve` on a database the ceremony has not certified.
21+
22+
**What an embedder does.** Anchor your administrators the way ADR-0131 D5 names: declare them in `OS_PLATFORM_OWNER_EMAIL` (their accounts verified), or give them an owner or admin `sys_member` row. Or register the organization object (`sys_organization`, which plugin-auth ships) and own the grant by the Default Organization. On a database 17.x or an 18.0 pre-release created, run `os migrate organization-ownership`, which attributes every organization-less grant to an organization.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/plugin-sharing': patch
4+
---
5+
6+
fix(spec,plugin-sharing): the `DataEvent.organizationId` description and the sharing-rule Organization help stop describing organization-less rows that 18.x no longer writes
7+
8+
Clause-②: no (description and help text only: no response, status, outcome, boot verdict or export moves)
9+
Metadata-protocol: none (a schema description and a field's help text; nothing a metadata author writes changes)
10+
11+
- **`DataEvent.organizationId` (`@objectstack/spec/api`).** The description said every event on a `single`-posture deployment carries no organization, because nothing stamps the column there. Since 18.x the engine stamps every tenant row on a single-organization deployment with its Default Organization, so those events carry it. The key is now described as absent in two cases: a row of an object outside the wall, and every row of a composition that registers no organization object. The schema itself is unchanged (`z.string().min(1).optional()`).
12+
- **`sys_sharing_rule.organization_id` (`@objectstack/plugin-sharing`).** The Setup help said "null = global". A stored rule always carries its organization; a rule that applies to every organization is a declared rule. The help now reads "The organization this rule belongs to.", with the translated locales updated to match.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(service-automation,runtime): a flow whose data node is refused answers its caller with the refusal structured — `details.innerRefusal` and the producer-marked `userMessage` on every door's `400 FLOW_FAILED` — beside an unchanged `message`
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
Ruling B′ on #24914 (the maintainer's 「同意」, decision batch #379 item 5), the emission half of the contract `@objectstack/spec` declared in #24996. A record save the platform refuses reaches a record form as `code`, `message` and the producer-marked `userMessage`; the same refusal met by a flow's data node used to be flattened into one string (`Node 'ID' failed: create_record(OBJECT) failed: …`), so a screen flow could only show its user that developer text.
12+
13+
- **`service-automation`.** The catch arms of `get_record`, `create_record`, `update_record` and `delete_record` read the refusal off the thrown error: the producer-marked `userMessage` (through `declaredUserMessage`) and, for a throw that declared a 4xx answer, `{ code, message }` with `code` in the WIRE spelling the data door answers for the same throw — `UNIQUE_VIOLATION` for a unique refusal, `VALIDATION_FAILED`, `PERMISSION_DENIED`, or the member an app hook's declared status names. `NodeExecutionResult` gains the two optional members, and the engine carries them from the failing node to `AutomationResult.userMessage` / `.innerRefusal` on `execute()`'s failure exit, the `resumeInternal` stranded exit and the retry exits. A fault (no declared answer, or a declared 5xx) carries neither member; nothing is read from message text.
14+
- **`runtime`.** The trigger, resume, `/actions` and declared-endpoint doors build their `400 FLOW_FAILED` details through one builder typed as `FlowFailureDetailsSchema` (`errorMessage?`, `summary?`, `innerRefusal?`) and put the run's `userMessage` at the envelope's top level (`ApiErrorSchema.userMessage`). The MCP `resume_run` bridge carries both as the REST resume door serves them. `DomainHandlerDeps.error` gains an optional fourth argument, `extra: { userMessage? }`, the declared slot the doors use; a call without it builds the envelope it always built.
15+
- **Unchanged.** Every `error` / `message` text, byte for byte — the node's, the engine's prefix, the stored run, approvals and each door's wording. The in-flow `$error.code` keeps the engine's in-process `DUPLICATE_RECORD`. `error.code` stays `FLOW_FAILED`, and `AutomationResult.code` stays the closed union.
16+
17+
**For consumers.** A flow runner can render a refused run the way a record form renders a refused save: the top-level `userMessage` when present, then `details.innerRefusal`, then `message`. A client that does not read the new members keeps its current behaviour. A container that rewords a child's failure into its own (`try_catch` without a catch region, `subflow`) does not forward the members yet; the run then carries none.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os lint` and `os i18n check` ask for an approval flow's label and each approval step's label (#24916)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The approvals door names a request's flow and its current step in the reader's locale, from `flows.FLOW.label` and `flows.FLOW.approvals.NODE.label`. The i18n extractor asked for `flows.FLOW.label` only on a flow with a `screen` node, and for no approval step at all, so `i18n/missing-flow` never listed an approval flow or its steps as untranslated, and `os i18n extract` never scaffolded those keys. An app's approvals inbox could show the authoring language in every other locale under a green coverage run.
11+
12+
Now:
13+
14+
- `flows.FLOW.label` is asked for on a flow with a `screen` node or an `approval` node, the two surfaces that show it. A flow with neither, such as a record-triggered or scheduled flow that only does work, still owes no label, because nothing shows it.
15+
- `flows.FLOW.approvals.NODE.label` is asked for on every `approval` node that has an `id`, seeded with the node's own `label`.
16+
- Both are reported as `i18n/missing-flow` by `os lint`, listed by `os i18n check`, and written into the skeleton by `os i18n extract`. `os i18n check --help` names approval steps among the surfaces it covers.
17+
18+
**What you may see.** An app that ships an approval flow and declares a locale beyond its default now gets an `i18n/missing-flow` finding for each untranslated approval flow and step. Under `os lint --i18n-strict` or `os i18n check --strict` these are errors, like every other missing translation. To clear them, translate the keys in that locale's pack, for example:
19+
20+
```ts
21+
flows: {
22+
expense_approval: {
23+
label: '报销审批',
24+
approvals: { manager_review: { label: '经理审核' } },
25+
},
26+
},
27+
```
28+
29+
`os i18n extract` writes the empty slots for you and keeps the strings a pack already translates. No key, schema or export changes: both keys were already part of the translation contract, and `os validate` already accepted them.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/driver-turso': minor
4+
'@objectstack/service-datasource': minor
5+
---
6+
7+
feat(spec,driver-turso)!: turso `config.encryptionKey` is removed — it never encrypted anything, in any mode, and is now refused everywhere it is written instead of accepted on a remote datasource
8+
9+
Clause-②: no (narrowing)
10+
11+
Metadata-protocol: narrowed
12+
13+
<!-- adr-0087: registered turso-config-encryption-key-removed, turso-config-encryption-key-retired -->
14+
15+
`encryptionKey` was declared as an AES-256 key for the database file. Nothing ever applied it. A local or embedded-replica file is read and written through better-sqlite3, which has no encryption codec, so #25020 already refused the key there. A remote database was never handed the key either: its storage encryption is configured on the server. That left a key that parsed on a remote datasource and did nothing. It was also the one credential-shaped key a datasource could still write in plain text (`STILL_WRITABLE_CREDENTIAL_KEYS`). Under ADR-0049 (enforce-or-remove) it is removed.
16+
17+
**BREAKING** accept-set narrowing on a published schema, a published constructor and an exported type. It ships as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`).
18+
19+
- **`@objectstack/spec`**: `TursoConfigSchema.encryptionKey` is a `retiredKey()` tombstone. Authoring it is a `tsc` error and a parse error at `config.encryptionKey`, in every mode and with any value, the empty string included. The error carries the removal prescription. It is the only issue reported on that config, whatever else is wrong. The misspellings `encryption_key` and `encryptionkey` get the same prescription. The #25020 local and replica refusal arms are gone, since the tombstone subsumes them.
20+
- **`@objectstack/driver-turso`**: the published mirror `TursoConfigSchema` refuses the key the same way, as `z.never()`. `TursoDriverConfig.encryptionKey` is typed `never`. `new TursoDriver()` and `createTursoDriver()` refuse a defined `encryptionKey` with `VALIDATION_ERROR` / 400 in every mode, before anything else is checked, in the schema's words.
21+
- **`@objectstack/service-datasource`**: the exported `TursoDriverConfigInput.encryptionKey` is typed `never`. The shared loader still forwards a declared value, so a legacy stored row reaches the constructor's refusal instead of being dropped in silence.
22+
23+
### Migration: FROM → TO
24+
25+
| You wrote | Write instead |
26+
| --- | --- |
27+
| `url: 'libsql://my-db.turso.io', encryptionKey: '…'` (also `https://`, `ws(s)://`) | delete `encryptionKey`. Storage encryption for a remote database is configured on the Turso server |
28+
| `encryptionKey: ''` (or any unset value) | delete `encryptionKey` |
29+
| `url: 'file:./data/app.db', encryptionKey: '…'` (also `:memory:`, or a replica beside `syncUrl`) | delete `encryptionKey` (already refused since #25020). Put the file on an encrypted disk or volume if it must be encrypted at rest |
30+
31+
The fix in one line: delete `encryptionKey` from every turso datasource.
32+
33+
### The ADR-0087 disposition
34+
35+
- **D2 `turso-config-encryption-key-removed`** deletes the key from a turso datasource's `config` when its value is unset (any non-string, or `''`) or when it sits beside a remote url (`libsql://`, `https://`, `http://`, `ws://`, `wss://`, in any case). It emits one notice per key. Nothing is lost: the driver never read the value.
36+
- A non-empty key beside a local url is **not** deleted. Removing it would let a datasource whose operator believed the file was encrypted boot in silence. It keeps the #25020 posture instead: the row fails at driver construction with the prescription, and the author deletes the key by hand.
37+
- The conversion is retired from the load path. A live author is refused at parse and taught. Stored rows (the datasource loaders), built artifacts inside their declared-floor window and `os migrate meta --from 17` replay it.
38+
- `RETIRED_KEYS_BY_MAJOR[18]` gains `data/TursoConfig:encryptionKey`. The D3 entry is `turso-config-encryption-key-retired`.
39+
40+
### Stored rows stay redacted
41+
42+
A tombstone is excluded from the schema-derived credential list, by its `[REMOVED]` prefix. Measured: after the retirement, no existing path would have withheld a stored `encryptionKey` on read. The derived turso list (`authToken`) is non-empty, so the canonical fallback never applies. `STILL_WRITABLE_CREDENTIAL_KEYS` is therefore renamed `RETIRED_CREDENTIAL_KEYS` and keeps `turso: ['encryptionKey']`. A legacy row's value is still withheld and still named in `redactedConfigKeys`, on the admin door and the metadata door alike.
43+
44+
### Blast radius
45+
46+
Measured on this tree: no example, template, CLI scaffold, published skill, host default or environment variable authors the key. A tree-scoped absence pin now holds that. Whether any out-of-repo deployment declares it is not measured, and is not claimed to be zero.

0 commit comments

Comments
 (0)