Skip to content

Commit 30a3bcc

Browse files
Merge branch 'main' into claude/issue-23050-s13e-meta-object-state-published
2 parents f2c84cc + 33f700b commit 30a3bcc

47 files changed

Lines changed: 2361 additions & 136 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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
`sys_organization` now declares a `rename_organization` action ("Rename Organization") at `record_header` and `list_item`, so an organization record page can rename the organization. It collects `name` (required) and `logo`, both pre-filled from the record, and no `slug`. It calls better-auth's `/api/v1/auth/organization/update` exactly as `update_organization` does (the record's id as `organizationId`, the fields under `data`), and it is gated on `multiOrgEnabled` like the other organization actions. Because the request carries no slug, the organization keeps its slug. It is declared before `change_slug`, so on the record header it takes the third inline slot and `change_slug` moves into the `⋯` overflow menu.
7+
8+
`update_organization` (name, slug and logo, list row only) and `change_slug` are unchanged.
9+
10+
`@objectstack/spec` books the new action in `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs`, beside `update_organization`.
11+
12+
Clause-②: yes
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/plugin-auth': minor
5+
---
6+
7+
Clause-②: yes (widening)
8+
9+
fix(cli,runtime): the 17 → 18 organization-ownership upgrade of a single-posture database that holds no organization leaves no row without one, the boot the ceremony prescribes writes nothing before it is refused, and a ceremony that did not verify is never served (ADR-0131 D3, D10)
10+
11+
A 17.x single-posture install whose seed ran before any organization existed holds rows of organization-owned objects with `organization_id` NULL and an empty `sys_organization`. Upgrading it took this path: the ceremony's attribution checkpointed itself done at zero rows; the boot its rewrite step asks for created the Default Organization and let the inline seed stamp some rows before it refused; the second `--apply` did not run attribution again, dropped the columns and recorded discrepancies with `verified_at` NULL; and `os start` then served that database.
12+
13+
- **The ceremony creates the Default Organization (`@objectstack/cli`).** Under the `single` posture, on a database that holds no organization and rows no anchor names, `os migrate organization-ownership` plans those rows as the Default Organization's and says so in a plan warning (`defaultOrganizationCreatedByApply: true` in the plan file). `--apply` creates it before its first write, through plugin-auth's boot invariant (the row a single-posture boot creates), and gives it every such row. A run that cannot create it — a boot without the auth family that declares `sys_organization` — blocks at attribution, naming why, instead of attributing nothing and certifying. Nothing is created under a walled posture, or when the database holds organizations.
14+
- **The ceremony names the Default Organization as the boot does (`@objectstack/cli`).** Under `single` it is the organization with `slug = 'default'`, else the sole organization whatever its slug — the boot invariant's and the tenancy service's definition (ADR-0093 D3). Before, the plan required `slug = 'default'`, so a database whose only organization had another slug kept its unanchored rows NULL through the ceremony while v18 derives every owner from that organization. Several organizations, none of them `default`, still name none, and their rows are reported. In the plan file (the command's own output, not a published schema) `defaultOrganization.slug` is now that organization's own slug, no longer always `'default'`.
15+
- **A checkpointed attribution runs again (`@objectstack/cli`)** whenever the plan derives an owner for rows still NULL: a row the operator made attributable since, or one an earlier run found no Default Organization for. It writes only where `organization_id` is NULL, so no owner already written is derived again. A database left by an earlier release's ceremony in that state is completed by re-running `--apply`.
16+
- **The boot guard judges before any plugin writes (`@objectstack/runtime`, `@objectstack/plugin-auth`).** `OrganizationOwnershipBootGuardPlugin` judges in its own `start()`, after the engine's schema sync (the column the rewrite step waits for is still added) and before every plugin that writes rows at start: `AppPlugin` (the inline seed), `AuthPlugin` and the identity-objects plugin (the Default Organization) name it (`com.objectstack.organization-ownership-boot-guard`) in `optionalDependencies`. A refused boot leaves every row as it found it.
17+
- **A ceremony that did not verify is refused (`@objectstack/runtime`).** With no table left in the v17 shape, a boot whose `adr-0131-organization-ownership` marker exists and is not verified — the post-check found rows the ceremony has not carried — is refused with `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`, naming the re-run. A database with no marker (a fresh one) boots as before.
18+
- **The refusal now arrives as a `cause` (`@objectstack/runtime`).** A failed `start()` is reported by the kernel as its own error ("Plugin … failed to start"), with the plugin's error as its `cause`. A host that composes the guard and inspects the boot error asks `isOrganizationOwnershipBootRefusal` of each `cause` down the chain: asked of the kernel's own error it now answers `false`. `assertOrganizationOwnershipMigrated(engine)` still throws the refusal itself. `os serve` and `os dev` read it from the chain.
19+
- **One new export (`@objectstack/plugin-auth`, `minor`).** `ensureDefaultOrganizationExists(ql, options?)` — the Default Organization's boot invariant, find-or-create under `single` — for the ceremony, so the organization it creates is the row a single-posture boot creates, from one producer. A host composing a kernel still never calls it: `AuthPlugin` and the identity-objects plugin keep the invariant on every boot. No other package entry changes.
20+
21+
Unchanged: the ceremony's steps, fates and refusals otherwise; a walled posture's unattributable rows (reported, never assigned); the boot of a fresh or verified database; and every plugin's order on a kernel that does not compose the guard. Repairing a database whose organization-scoped seed load already inserted twins of NULL-organization rows is an operator's data repair.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): global search judges an object's searchable set without its `internal: true` fields, the set the engine scans
6+
7+
Global search (`searchAll`, `GET /api/v1/search`) passes over an object with nothing to scan before it queries. It judged that from the object's fields including `internal: true` ones, which the engine never searches. An object whose only text field is internal therefore passed the skip and reached the engine, which now refuses a search over an empty set, so the whole sweep would have failed. The skip now leaves internal fields out, so such an object is passed over and the sweep answers `200` with the other objects' hits.
8+
9+
What to do. Nothing.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
feat(objectql)!: a `search` over an object with no searchable field is refused with `400 INVALID_FIELD` instead of answering every row
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No spec key, stored shape or authorable spelling is added, removed or renamed. The narrowing is in what a read answers: a non-blank `search` term over an object whose resolved search set is empty used to be dropped (200, every row) and is now a 400 that names the object and the remedy. The remedy is a metadata edit the author makes by hand (declare `searchableFields`, or add a stored text field), so there is nothing stored for `objectstack migrate meta` to rewrite. -->
10+
11+
A search term is matched against the object's resolved search fields: its `searchableFields` declaration (entries that name a stored field), or else the default set (the name/title field and the text, email, phone, url, autonumber, textarea, markdown and select fields). On an object where that set is empty, such as a junction object made of two lookups and a formula title, the term used to add no filter, so `?search=zzzz-nothing` and `?search=Dune` returned the same rows, and a user read every row as a match.
12+
13+
- **What changes.** `find`, `findOne`, `count` and `aggregate` answer a non-blank `search` over an empty resolved set with `400 INVALID_FIELD` (`param: 'search'`). That covers every door that reaches the engine: the REST list and query routes (and the list's `total`), the grouped read, the dispatcher, the SDK and a hook's `ctx.api`. The message names the object, says why the set is empty, and gives the two remedies.
14+
- **Unchanged.** A blank or whitespace term is not a search and still answers every row. An object with a searchable field filters as before. An explicit `searchFields` naming a field outside the set keeps its own `400 INVALID_FIELD` at the data door. Global search (`GET /api/v1/search`) still passes over an object with nothing to scan and answers `200`.
15+
16+
What to do. If a read now answers `400 INVALID_FIELD` with "has no searchable field", declare `searchableFields` on that object naming a stored field, or mirror the composed title onto a stored text field and search that. A client that offers a search box on such an object should hide it, or show the refusal.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/hono": patch
3+
---
4+
5+
fix(hono): on a `createHonoApp` host, `GET /api/v1/mcp/skill` answers the raw markdown with one `Content-Type: text/markdown; charset=utf-8` and `Cache-Control: no-store`, as the standalone server does
6+
7+
Clause-②: no
8+
9+
- **What a caller got.** The skill is served as a single string event on the dispatcher's `result` stream channel. The adapter's stream writer framed every event outside a Vercel data stream as `data: <JSON>`, so the body was an SSE frame of a JSON-encoded string (`data: "---\nname: …"`), not markdown. It also set its own `Content-Type` and `Cache-Control` beside the domain's, so the answer carried `text/markdown; charset=utf-8, text/markdown; charset=utf-8` and `no-cache, no-store` (on a socket: two `Content-Type` lines and two `Cache-Control` lines).
10+
- **What it answers now.** A string event is written as given, in every stream mode, and a header the producer sets replaces the adapter's default of the same name, whatever its case. Object events are still one `data:` frame each (one JSON line in a Vercel data stream), and a stream that sets no headers still gets `text/event-stream`, `no-cache` and `keep-alive`.
11+
- **Unchanged:** the AI chat streams, which reach the same writer as pre-encoded Vercel data stream frames or as object parts, and every other route.
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+
feat(spec): `ApprovalRequestRow` declares `current_step_label`, the `sys_approval_request` column's own name for the step's label
6+
7+
Clause-②: yes (widening)
8+
9+
- **The member.** `ApprovalRequestRow` (`@objectstack/spec/contracts`) gains an optional `current_step_label?: string` beside `step_label`. It names the declared `sys_approval_request.current_step_label` column: the Approval node's authored label as it stood when the request reached the step. A page bound to `sys_approval_request` names the object's declared fields, and `step_label` is not one, so a served row that carries this member lets such a page list the step by its label instead of by `current_step`, the node's machine id.
10+
- **Nothing moves.** `step_label` stays, at the same shape and with the same value, so no reader changes. The member is optional, so every existing implementer and every row built without it still conforms. It is a member of a TypeScript interface: no Zod schema and no runtime accept set changes.
11+
- **Its producer.** `@objectstack/plugin-approvals`' request read mapping is the producer; serving the member there is that package's own change.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Clause-②: no
6+
7+
The `source` describe on `HistoryMetaItemResponseSchema`'s events, the body of
8+
`GET /api/v1/meta/:type/:name/history`, no longer lists `"fs"`, `"studio"`, `"rest"`, `"ai"` and
9+
`"git-import"` as its labels: none of the write paths in `@objectstack/metadata-protocol` whose history
10+
rows this door reads stamps any of them. It now says what the field is: an open code-path label the
11+
server stamps, not an enum an author or client picks from, for example `protocol.saveMetaItem`,
12+
`protocol.publishMetaItem` or `migrate-stored`, with `sys-metadata-repo` as the repository's own default
13+
and the answer for a row that stored none. The field stays `z.string()`, so no key, type, export or
14+
accepted value changes, and the door answers exactly what it answered before.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2027,7 +2027,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
20272027
| **actor** | `string \| null` | ✅ | Who wrote this. `null` = system-initiated (boot sync, migration, scheduled job) — never a sentinel string: consumers that resolve this against `sys_user` must be able to tell "nobody" from "a user id". |
20282028
| **message** | `string` | optional | Optional commit message recorded with the write. |
20292029
| **ts** | `string` | ✅ | When the write happened (ISO-8601 string). |
2030-
| **source** | `string` | ✅ | Origin label of the write: "fs", "studio", "rest", "ai", "git-import", … |
2030+
| **source** | `string` | ✅ | Which code path wrote the event: an open label the server stamps, not an enum an author or client picks from (e.g. `protocol.saveMetaItem`, `protocol.publishMetaItem`, `migrate-stored`). The history row's stored value is passed through; `sys-metadata-repo` is the repository's own default and the answer for a row that stored none. |
20312031

20322032

20332033
---

‎docs/adr/0079-record-display-name.md‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- **Builds on**: [ADR-0078](./0078-no-silently-inert-metadata.md) (author-time completeness must not be cloud-only), [ADR-0061](./0061-record-search-architecture.md) (`$search` field resolution reads the display field)
88
- **Precedent for**: [ADR-0085](./0085-object-semantic-roles-over-surface-hint-blocks.md) (`compactLayout` → `highlightFields` follows this ADR's alias mechanics "exactly"), [ADR-0098](./0098-pinyin-search-companion-column.md) (the search companion column is fed by this ADR's resolved display field)
99
- **Consumers**: `@objectstack/spec` (`data/display-name.ts`, `data/object.zod.ts`, `data/search-fields.ts`, `ai/solution-blueprint.zod.ts`), `@objectstack/objectql` (registry materialization seam, `$search` expansion, search companion), `@objectstack/metadata-protocol` (REST `$searchFields` ingress gate), `@objectstack/lint` (`validate-record-title`, `data-model-rules` R9), `@objectstack/platform-objects` and the first-party plugins/services (41 explicit `nameField:` designations), the `objectstack-data` / `objectstack-formula` skills
10+
- **Addenda**: [2026-10-18 (#24197) — a formula title reads one hop through a reference, and is not sortable or searchable](#addendum-2026-10-18-24197--a-formula-title-reads-one-hop-through-a-reference-and-is-not-sortable-or-searchable)
1011

1112
---
1213

@@ -335,3 +336,79 @@ the 2026-06-28 discussion.
335336
gate and a query engine disagreeing about what is searchable.
336337
- **Accept `primaryField` as an additional tolerated alias.** Rejected
337338
explicitly by the maintainer in #6326 (D9).
339+
340+
---
341+
342+
## Addendum (2026-10-18, #24197) — a formula title reads one hop through a reference, and is not sortable or searchable
343+
344+
Records ruling 6245544505 (letter A, direction (a), on `objectstack-ai/cloud#2925`),
345+
shipped by #24274 (`d537a318b`) and #24307 (`1bf5f6e17c`). It states what is on
346+
`main` after those two changes and decides nothing more; no section above is edited.
347+
The ruling, verbatim:
348+
349+
> The formula read path preloads one hop of the referenced fields a formula
350+
> names (page-batched, as validation rules do); lint admits one hop and refuses
351+
> two; a title formula over lookups sorts and searches by its stored value or is
352+
> declared unsortable — written as an ADR-0079 note.
353+
354+
**The read path** (`find`, `findOne`, and the insert and update responses;
355+
`packages/objectql/src/engine.ts`):
356+
357+
1. A formula field reads ONE hop through a reference field (`lookup`,
358+
`master_detail`, `user`, `tree`), as `record.FIELD.RELATED`. The read path
359+
preloads the related fields the formula names once per page, with one batched
360+
read per reference field and never one per row, under the reader's own
361+
permissions.
362+
2. A related record that is missing, or that the reader may not read, leaves the
363+
formula `null` with a reported reason, never a computed fallback; the reason
364+
does not say which of the two held. An empty reference is not a missing
365+
record: the formula is evaluated over the empty value.
366+
3. A second hop, and a reference read both through and as a plain value, are
367+
refused by the lint (`traversal-multi-hop`, `traversal-bare-and-traversed`)
368+
and read `null` with the same reason at run time. A related object's own
369+
formula that reads a hop of its own reads `null` when it is itself read as a
370+
hop.
371+
4. Field-level security on the carrying object's own reference column does not
372+
stop the hop: formulas are computed before the field mask is applied to the
373+
result, as for any own column a formula reads. The control for a related name
374+
a reader may not see is field-level security on the formula field itself.
375+
376+
**Hook bodies** (`packages/runtime/src/sandbox/body-runner.ts`, the hook face):
377+
378+
5. `ctx.title()` evaluates the object's title formula over the in-flight record
379+
state the hook fires on, and serves its one hop the way a read does: one
380+
batched read per reference field, on the body's own `ctx.api` channel. That
381+
is the channel's identity (SYSTEM under `runAs: 'system'`) and its open
382+
transaction's connection. So a title over lookups names the related records
383+
the in-flight foreign keys point at, and a related record that channel may
384+
not read, a missing one, or a second hop leaves the title `null`, as the REST
385+
read of the same record shows no title.
386+
6. A channel that cannot read (a `runAs: 'user'` hook whose trigger resolved no
387+
user) serves no hop: a title over the record's own columns resolves, and a
388+
title over a reference answers `null`.
389+
7. Field-level security on the title field itself is not applied to
390+
`ctx.title()`.
391+
8. `ctx.title('FIELD')` answers the related record's title exactly as its
392+
`findOne` served it, that record's own one-hop title included.
393+
394+
**Sort and search — the ruling's "declared unsortable" arm:**
395+
396+
9. A formula value, a composite title over lookups included, is stored nowhere;
397+
it is computed on each read. A sort by it is refused (`400 INVALID_SORT`). A
398+
search cannot reach it: a request's `searchFields` naming it is refused
399+
(`400 INVALID_FIELD`), a `searchableFields` entry naming it is dropped, and
400+
the auto-default search set never includes it. An object that needs to sort
401+
or search by its composite title must store the value in a field of its own,
402+
written when its sources change.
403+
404+
**What this qualifies above, without editing it:**
405+
406+
- The first bullet of [Alternatives considered](#alternatives-considered) says a
407+
text formula `nameField` is something the server *can* return and query. For
408+
a title formula over lookups, the server returns it (items 1 and 5) but cannot
409+
sort or search by it (item 9).
410+
- D1 lists a text `formula` among the "real STORED" title fields. It is a
411+
declared field of the object, but no column holds its value (item 9).
412+
- The surfaces this addendum describes are the engine read path and the
413+
hook-body title accessor. It makes no statement about any other consumer of
414+
a record's title.

0 commit comments

Comments
 (0)