Skip to content

Commit a14895b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24289-inprocess-hook-ctx-title
2 parents 7bd31c8 + 51ad8e4 commit a14895b

23 files changed

Lines changed: 1397 additions & 115 deletions
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: 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.

‎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)