From ff753e13da33fa0326c927b91a751d9ae9176cb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:12:32 +0000 Subject: [PATCH 1/4] docs(changeset): the pending Hono currentRecord notes state what the release ships, amended in place The two earlier pending notes said a Hono door's 409 CONCURRENT_UPDATE holds currentRecord back; a later same-release change forwards it, the caller's own read of the row. Each note now states the shipped behaviour, and the later note's FROM names the body the last published prerelease answered, so no entry describes a body no release shipped. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .changeset/23531-hono-conflict-body.md | 6 +++--- .changeset/23531-hono-conflict-current-record.md | 8 ++++---- .changeset/23587-hono-structured-arm-fields.md | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.changeset/23531-hono-conflict-body.md b/.changeset/23531-hono-conflict-body.md index 63808f4da22..9ff97a6e308 100644 --- a/.changeset/23531-hono-conflict-body.md +++ b/.changeset/23531-hono-conflict-body.md @@ -8,12 +8,12 @@ fix(plugin-hono-server): a `409 CONCURRENT_UPDATE` answered by either Hono door A stale `If-Match` (or `expectedVersion`) on `PATCH` or `DELETE /data/:object/:id` is refused by the protocol with `409 CONCURRENT_UPDATE`. The standalone REST server's answer carries the server's `currentVersion`, which `wire-format.mdx` documents and a conflict dialog needs: it is the token "Overwrite anyway" sends back as the next `If-Match`. The two Hono doors answered the same refusal with `code` and `message` only. They are `@objectstack/hono`'s `createHonoApp` and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`. - FROM `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…"}}` -- TO `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…"}}}` +- TO `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…","currentRecord":{…}}}}` The field rides `error.details`, the wrapped envelope's declared context slot. `@objectstack/client` reads a wrapped body's context from there, so `err.details.currentVersion` now carries it on these hosts too. It is read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so it appears only when the conflict stated it. Status, `code` and `message` are unchanged, and no other answer changes. -`currentRecord` is held on these two doors until #23586 settles how the protocol reads that row, and the REST server's answer is unchanged. +`currentRecord` rides beside it, as on the REST server: the row as the caller reads it, which the protocol re-reads under the caller's own context on a version mismatch (#23586). -The `@objectstack/hono` entry for #23365 names this as a known gap. This change closes the gap's `currentVersion` half. Nothing changes in `@objectstack/hono`'s own code: `createHonoApp` renders through `declaredEnvelopeForThrow` from `@objectstack/plugin-hono-server`. +The `@objectstack/hono` entry for #23365 names this as a known gap. This change closes that gap. Nothing changes in `@objectstack/hono`'s own code: `createHonoApp` renders through `declaredEnvelopeForThrow` from `@objectstack/plugin-hono-server`. Clause-②: no diff --git a/.changeset/23531-hono-conflict-current-record.md b/.changeset/23531-hono-conflict-current-record.md index d24552f59a1..8ad875f279b 100644 --- a/.changeset/23531-hono-conflict-current-record.md +++ b/.changeset/23531-hono-conflict-current-record.md @@ -5,13 +5,13 @@ fix(plugin-hono-server): a `409 CONCURRENT_UPDATE` answered by either Hono door carries `currentRecord` as well, the caller's own read of the row, as the standalone REST server's does (#23531) -The two Hono doors are `@objectstack/hono`'s `createHonoApp` and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`. Their answer to a stale `If-Match` (or `expectedVersion`) on `PATCH` or `DELETE /data/:object/:id` carried the server's `currentVersion` in `error.details`, and held `currentRecord` back while the protocol read that row with a system context. On a version mismatch the protocol now re-reads the row under the caller's own context (#23586), so the record holds only what the caller may read, and these doors forward it. +The two Hono doors are `@objectstack/hono`'s `createHonoApp` and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`. Their answer to a stale `If-Match` (or `expectedVersion`) on `PATCH` or `DELETE /data/:object/:id` carried `code` and `message` only. On a version mismatch the protocol now re-reads the row under the caller's own context (#23586), so the record holds only what the caller may read, and these doors forward it beside the server's `currentVersion`. -- FROM `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…"}}}` +- FROM `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…"}}` - TO `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…","currentRecord":{…}}}}` -`currentRecord` is the row as the caller reads it. A field the caller's field-level rules hide is absent from it, and a row the caller's row-level rules hide answers `404 RECORD_NOT_FOUND` with neither field, as the REST server answers. It is read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so it appears only when the conflict stated it, and `@objectstack/client` reads it as `err.details.currentRecord` on these hosts. Status, `code`, `message` and `currentVersion` are unchanged, and no other answer changes. +`currentRecord` is the row as the caller reads it. A field the caller's field-level rules hide is absent from it, and a row the caller's row-level rules hide answers `404 RECORD_NOT_FOUND` with neither field, as the REST server answers. It is read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so it appears only when the conflict stated it, and `@objectstack/client` reads it as `err.details.currentRecord` on these hosts. Status, `code` and `message` are unchanged, and no other answer changes. -This supersedes the earlier note that `currentRecord` stays held on these two doors. Nothing changes in `@objectstack/hono`'s own code: `createHonoApp` renders through `declaredEnvelopeForThrow` from `@objectstack/plugin-hono-server`. +Nothing changes in `@objectstack/hono`'s own code: `createHonoApp` renders through `declaredEnvelopeForThrow` from `@objectstack/plugin-hono-server`. Clause-②: no diff --git a/.changeset/23587-hono-structured-arm-fields.md b/.changeset/23587-hono-structured-arm-fields.md index 53cda63b1d6..7ac6bf538dc 100644 --- a/.changeset/23587-hono-structured-arm-fields.md +++ b/.changeset/23587-hono-structured-arm-fields.md @@ -11,6 +11,6 @@ Deleting a record that a `deleteBehavior: 'restrict'` lookup still references is The same rule now covers every refusal the REST server answers with structured fields, not only `409 CONCURRENT_UPDATE`'s `currentVersion`: `400 INVALID_FIELD` carries `field` and `object`, `404 OBJECT_NOT_FOUND` carries `object`, the `403` capability and access refusals (`FEEDS_DISABLED`, `FILES_DISABLED`, `ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE`) carry `object`, and a `409 METADATA_CONFLICT` thrown out of a direct-mount route carries `currentVersion` (on `createHonoApp` the `/meta` save is answered by the dispatcher itself, which this does not change). The fields ride `error.details`, where `@objectstack/client` reads a wrapped body's context, so `err.details.developerMessage` and its siblings carry them on these hosts too. They are read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so each appears only when the producer stated it. Status, `code` and `message` are unchanged, and a refusal whose classification answers a different status or code (a duplicate insert's `DUPLICATE_RECORD`, which the REST server answers as `UNIQUE_VIOLATION`) gains nothing. -Two fields stay off these doors. `object` on `DELETE_RESTRICTED`, `CONCURRENT_UPDATE` and `METADATA_CONFLICT` is the object the request's route named, and these doors answer without a route. `CONCURRENT_UPDATE`'s `currentRecord` stays held until #23586 settles how the protocol reads that row. The REST server's answer is unchanged. +One field stays off these doors: `object` on `DELETE_RESTRICTED`, `CONCURRENT_UPDATE` and `METADATA_CONFLICT` is the object the request's route named, and these doors answer without a route. The REST server's answer is unchanged. Clause-②: no From 4ccf7bc3a5a035b1b11ef890ab9240ff23e914c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:22:55 +0000 Subject: [PATCH 2/4] docs(changeset): the pending organization_id drop-gate notes state what the release ships, amended in place The v18 boot-refusal note said os migrate apply --allow-destructive refuses the organization_id drop only on the ceremony's inventory tables; a later same-release change holds back every such drop. The note now states the shipped behaviour, and the later note's FROM names the behaviour the last published prerelease had (no drop held back), so no entry describes a gate no release shipped. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .changeset/23429-v18-boot-refusal.md | 4 ++-- .changeset/23684-drop-gate-host-tables.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/23429-v18-boot-refusal.md b/.changeset/23429-v18-boot-refusal.md index a3ef2ad320f..8699cd26756 100644 --- a/.changeset/23429-v18-boot-refusal.md +++ b/.changeset/23429-v18-boot-refusal.md @@ -17,13 +17,13 @@ A v18 runtime no longer serves a database it was never migrated for. At `kernel: FROM → TO: - A 17.x database started by an 18.x `os serve` → run `os migrate organization-ownership`, review its plan, then `os migrate organization-ownership --apply --plan-file FILE` with that plan file, and start again. To keep 17.x semantics on an unmigrated database, run a 17.x runtime. -- `os migrate apply --allow-destructive` on such a database (`@objectstack/cli`, BREAKING `minor`: the command narrows) → it now refuses to drop `organization_id` from any table in the ceremony's inventory (the D7 tables and the `sys_metadata` family) while the ceremony's marker is not verified. Before, the drift report offered each such column as an unmapped destructive column, and the apply dropped it. Dropped ahead of the ceremony, that column takes the ceremony's promotion and report steps with it, turns every legacy organization row into an environment row, and leaves the boot refusal no column to find. The refusal names `os migrate organization-ownership`, exits 1, and under `--json` answers `message: 'refused_organization_ownership_unmigrated'` with the `tables`, as the `sys_account.issuer` (#17440) and `sys_comment.reactions` (#23281) refusals do. The refusal holds the WHOLE `--allow-destructive` run, as those two do: nothing in it is created, applied or dropped, every other destructive drop and the additive work included. A run without `--allow-destructive` is unchanged. The ceremony drops these columns itself, in its column-drops step, before it records the run verified. +- `os migrate apply --allow-destructive` on such a database (`@objectstack/cli`, BREAKING `minor`: the command narrows) → it now refuses to drop `organization_id` from any table (the D7 tables and the `sys_metadata` family among them) while the ceremony's marker is not verified. Before, the drift report offered each such column as an unmapped destructive column, and the apply dropped it. Dropped ahead of the ceremony, that column takes the ceremony's promotion and report steps with it, turns every legacy organization row into an environment row, and leaves the boot refusal no column to find. The refusal names `os migrate organization-ownership`, exits 1, and under `--json` answers `message: 'refused_organization_ownership_unmigrated'` with the `tables`, as the `sys_account.issuer` (#17440) and `sys_comment.reactions` (#23281) refusals do. The refusal holds the WHOLE `--allow-destructive` run, as those two do: nothing in it is created, applied or dropped, every other destructive drop and the additive work included. A run without `--allow-destructive` is unchanged. The ceremony drops these columns itself, in its column-drops step, before it records the run verified. - There is no escape hatch: no `OS_ALLOW_*` or `OS_SKIP_*` variable and no flag skips either check. What does not change: - A fresh database boots: no table it creates carries a retired column, so no marker is needed. - A database whose ceremony verified boots, whatever its columns. -- The `os migrate` commands boot as before, the ceremony's own among them: the boot guard is composed by `os serve` only, never by the shared standalone stack (`createStandaloneStack`, which `bootSchemaStack` and every other CLI data and schema boot build on). Only an `os migrate apply --allow-destructive` run that would drop an inventory table's `organization_id` is refused, as above. +- The `os migrate` commands boot as before, the ceremony's own among them: the boot guard is composed by `os serve` only, never by the shared standalone stack (`createStandaloneStack`, which `bootSchemaStack` and every other CLI data and schema boot build on). Only an `os migrate apply --allow-destructive` run that would drop an `organization_id` column is refused, as above. - A driver with no physical columns to read (memory, MongoDB) is not checked. - A host that composes its own kernel can add the guard itself: `OrganizationOwnershipBootGuardPlugin`, or `assertOrganizationOwnershipMigrated(engine)` from `@objectstack/runtime`. `ORGANIZATION_OWNERSHIP_MIGRATION_ID` is now exported from `@objectstack/spec/system`. diff --git a/.changeset/23684-drop-gate-host-tables.md b/.changeset/23684-drop-gate-host-tables.md index 68d5214899a..c5f6f177888 100644 --- a/.changeset/23684-drop-gate-host-tables.md +++ b/.changeset/23684-drop-gate-host-tables.md @@ -12,9 +12,9 @@ Clause-②: no (narrowing) **The contract this meets.** The drop gate's own header (`packages/cli/src/utils/organization-ownership-drop-gate.ts`) already states it: the drop of `organization_id` on a table the ceremony owns waits for the ceremony's verified marker, and "⛔ No flag stands it down". ADR-0131 D10 item 5 is the rule behind it: a v18 runtime refuses to boot against a database the ceremony has not completed, and offers no escape hatch that silently skips that check. A column dropped ahead of the ceremony is that escape hatch, because the boot refusal then finds no column to refuse over. -**FROM.** The gate held back only the tables of the platform inventory. Every other `organization_id` drop the schema-drift report offered was applied by `os migrate apply --allow-destructive` with no ceremony marker on the database: on the objects a host declares platform-global (ADR-0131 D7, the `org-scoping` service's `platformGlobalObjects`), and on any host or application object that declares no organization column of its own. Measured on a host composition that declares two objects platform-global: `os migrate apply --database-url file:v17.db --allow-destructive --yes --json` exited 0 and applied `drop_column organization_id` on both. +**FROM.** `os migrate apply --allow-destructive` held back no `organization_id` drop. Every one the schema-drift report offered was applied with no ceremony marker on the database, among them those on the objects a host declares platform-global (ADR-0131 D7, the `org-scoping` service's `platformGlobalObjects`) and on any host or application object that declares no organization column of its own. Measured on a host composition that declares two objects platform-global: `os migrate apply --database-url file:v17.db --allow-destructive --yes --json` exited 0 and applied `drop_column organization_id` on both. -**TO.** The gate holds back every `organization_id` drop the drift offers. The ceremony's plan meets every physical table that carries the column, with an inventory row, a `--fate-supplement` row, the application-object fate, or a refusal by name; the drift offers only a column that is physically there. So this is the ceremony's own table set, read without a copy of its inventory and without the fate-supplement file `os migrate apply` is never handed. It includes an application object that declares `systemFields: { tenant: false }` over a table that still carries the column: the ceremony plans that table, and the v18 boot refusal refuses over it too. While the `sys_migration` row `adr-0131-organization-ownership` is not verified, the run is refused with the existing refusal. It exits 1 and prints "Refusing this --allow-destructive run: it would drop organization_id from TABLES", naming `os migrate organization-ownership --out PLAN`, then `os migrate organization-ownership --apply --plan-file PLAN`, as the remedy. Under `--json` it answers `message: 'refused_organization_ownership_unmigrated'` with the `tables`, the `detail` and the `marker`. As before, the refusal holds the whole `--allow-destructive` run: nothing in it is created, applied or dropped. +**TO.** The gate holds back every `organization_id` drop the drift offers. The ceremony's plan meets every physical table that carries the column, with an inventory row, a `--fate-supplement` row, the application-object fate, or a refusal by name; the drift offers only a column that is physically there. So this is the ceremony's own table set, read without a copy of its inventory and without the fate-supplement file `os migrate apply` is never handed. It includes an application object that declares `systemFields: { tenant: false }` over a table that still carries the column: the ceremony plans that table, and the v18 boot refusal refuses over it too. While the `sys_migration` row `adr-0131-organization-ownership` is not verified, the run is refused with the existing refusal. It exits 1 and prints "Refusing this --allow-destructive run: it would drop organization_id from TABLES", naming `os migrate organization-ownership --out PLAN`, then `os migrate organization-ownership --apply --plan-file PLAN`, as the remedy. Under `--json` it answers `message: 'refused_organization_ownership_unmigrated'` with the `tables`, the `detail` and the `marker`. The refusal holds the whole `--allow-destructive` run: nothing in it is created, applied or dropped. **Unchanged.** Once the ceremony's marker is verified, the drop applies. A destructive change to any other column applies as before. A run without `--allow-destructive` is unchanged. There is no new flag, and no flag skips the refusal. From dc8f243f3a7dd0ee4cfeb58e756c60e40755b8fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:41:21 +0000 Subject: [PATCH 3/4] docs(changeset): fold the family's two remaining pending notes into the in-place amendment The createHonoApp note's known-gap paragraph now states the two fields the release's 409 carries. The sys_metadata family note states the trail doors' admission after the setup.access narrowing. The two Hono conflict notes name each door's last-published answer in their FROM: a HonoHttpServer route answered code and message, createHonoApp a 500. The trail-door note no longer says the data door "already" refused. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .changeset/23365-hono-declared-envelope.md | 9 +++------ ...29-sys-metadata-family-organization-column-retired.md | 2 +- .changeset/23531-hono-conflict-body.md | 8 ++++---- .changeset/23531-hono-conflict-current-record.md | 6 +++--- .changeset/23546-trail-doors-setup-access.md | 2 +- 5 files changed, 12 insertions(+), 15 deletions(-) diff --git a/.changeset/23365-hono-declared-envelope.md b/.changeset/23365-hono-declared-envelope.md index a072eb05396..167764cbdf0 100644 --- a/.changeset/23365-hono-declared-envelope.md +++ b/.changeset/23365-hono-declared-envelope.md @@ -48,11 +48,8 @@ Earlier entries say a `createHonoApp` host still renders the `/data` version refusals as `500`. This change ends that: they are answered `409` and `400` as declared. -⚠️ **Known gap, stated rather than left silent.** The `409 CONCURRENT_UPDATE` -this host now answers carries `code` and `message`, but not the -`currentVersion` and `currentRecord` that the standalone REST server's `409` -carries. The recogniser forwards only the `details` a producer declares, and -those two fields are own properties of the error. That gap is outside this -change. +The `409 CONCURRENT_UPDATE` this host now answers carries `currentVersion` and +`currentRecord` in `error.details`, the two fields the standalone REST server's +`409` carries; `currentRecord` is the row as the caller reads it. Clause-②: yes (widening) diff --git a/.changeset/23429-sys-metadata-family-organization-column-retired.md b/.changeset/23429-sys-metadata-family-organization-column-retired.md index 23bc7a96f4d..a70dade6222 100644 --- a/.changeset/23429-sys-metadata-family-organization-column-retired.md +++ b/.changeset/23429-sys-metadata-family-organization-column-retired.md @@ -46,7 +46,7 @@ What else changes: - Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after: - `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused. - The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused. - - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history` and `/audit` admit `studio.access`, `setup.access` or `manage_metadata`; `/packages/:id/commits` admits `studio.access` or `setup.access`. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `setup.access` or only `manage_metadata` therefore still reads these rows through `/meta`, but not through the data door. + - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history`, `/audit` and `/diff` admit `studio.access` or `manage_metadata`, and `/packages/:id/commits` admits `studio.access` alone; `setup.access` alone is refused at all four. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `manage_metadata` therefore still reads the history and audit rows through `/meta`, but not the commit rows, and none of them through the data door. - `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization. - On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column: - every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too; diff --git a/.changeset/23531-hono-conflict-body.md b/.changeset/23531-hono-conflict-body.md index 9ff97a6e308..98d926f30ae 100644 --- a/.changeset/23531-hono-conflict-body.md +++ b/.changeset/23531-hono-conflict-body.md @@ -5,15 +5,15 @@ fix(plugin-hono-server): a `409 CONCURRENT_UPDATE` answered by either Hono door carries `currentVersion`, as the standalone REST server's does (#23531) -A stale `If-Match` (or `expectedVersion`) on `PATCH` or `DELETE /data/:object/:id` is refused by the protocol with `409 CONCURRENT_UPDATE`. The standalone REST server's answer carries the server's `currentVersion`, which `wire-format.mdx` documents and a conflict dialog needs: it is the token "Overwrite anyway" sends back as the next `If-Match`. The two Hono doors answered the same refusal with `code` and `message` only. They are `@objectstack/hono`'s `createHonoApp` and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`. +A stale `If-Match` (or `expectedVersion`) on `PATCH` or `DELETE /data/:object/:id` is refused by the protocol with `409 CONCURRENT_UPDATE`. The standalone REST server's answer carries the server's `currentVersion`, which `wire-format.mdx` documents and a conflict dialog needs: it is the token "Overwrite anyway" sends back as the next `If-Match`. The two Hono doors answered the same refusal without it. They are `@objectstack/hono`'s `createHonoApp`, which answered it `500 INTERNAL_ERROR` (the `@objectstack/hono` entry for #23365 makes it a `409`), and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`, which answered it with `code` and `message` only. -- FROM `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…"}}` +- FROM `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…"}}` on a `HonoHttpServer` route, and `500` `{"success":false,"error":{"code":"INTERNAL_ERROR","message":"…"}}` on `createHonoApp` - TO `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…","currentRecord":{…}}}}` -The field rides `error.details`, the wrapped envelope's declared context slot. `@objectstack/client` reads a wrapped body's context from there, so `err.details.currentVersion` now carries it on these hosts too. It is read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so it appears only when the conflict stated it. Status, `code` and `message` are unchanged, and no other answer changes. +The field rides `error.details`, the wrapped envelope's declared context slot. `@objectstack/client` reads a wrapped body's context from there, so `err.details.currentVersion` now carries it on these hosts too. It is read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so it appears only when the conflict stated it. On a `HonoHttpServer` route, status, `code` and `message` are unchanged; on `createHonoApp` they are the `409` the `@objectstack/hono` entry for #23365 states. No other answer changes. `currentRecord` rides beside it, as on the REST server: the row as the caller reads it, which the protocol re-reads under the caller's own context on a version mismatch (#23586). -The `@objectstack/hono` entry for #23365 names this as a known gap. This change closes that gap. Nothing changes in `@objectstack/hono`'s own code: `createHonoApp` renders through `declaredEnvelopeForThrow` from `@objectstack/plugin-hono-server`. +Nothing changes in `@objectstack/hono`'s own code: `createHonoApp` renders through `declaredEnvelopeForThrow` from `@objectstack/plugin-hono-server`. Clause-②: no diff --git a/.changeset/23531-hono-conflict-current-record.md b/.changeset/23531-hono-conflict-current-record.md index 8ad875f279b..de9597abae3 100644 --- a/.changeset/23531-hono-conflict-current-record.md +++ b/.changeset/23531-hono-conflict-current-record.md @@ -5,12 +5,12 @@ fix(plugin-hono-server): a `409 CONCURRENT_UPDATE` answered by either Hono door carries `currentRecord` as well, the caller's own read of the row, as the standalone REST server's does (#23531) -The two Hono doors are `@objectstack/hono`'s `createHonoApp` and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`. Their answer to a stale `If-Match` (or `expectedVersion`) on `PATCH` or `DELETE /data/:object/:id` carried `code` and `message` only. On a version mismatch the protocol now re-reads the row under the caller's own context (#23586), so the record holds only what the caller may read, and these doors forward it beside the server's `currentVersion`. +The two Hono doors are `@objectstack/hono`'s `createHonoApp` and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`. Their answer to a stale `If-Match` (or `expectedVersion`) on `PATCH` or `DELETE /data/:object/:id` carried neither field: the route answered it with `code` and `message` only, and `createHonoApp` answered it `500 INTERNAL_ERROR`, which the `@objectstack/hono` entry for #23365 makes a `409`. On a version mismatch the protocol now re-reads the row under the caller's own context (#23586), so the record holds only what the caller may read, and these doors forward it beside the server's `currentVersion`. -- FROM `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…"}}` +- FROM `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…"}}` on a `HonoHttpServer` route, and `500` `{"success":false,"error":{"code":"INTERNAL_ERROR","message":"…"}}` on `createHonoApp` - TO `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…","currentRecord":{…}}}}` -`currentRecord` is the row as the caller reads it. A field the caller's field-level rules hide is absent from it, and a row the caller's row-level rules hide answers `404 RECORD_NOT_FOUND` with neither field, as the REST server answers. It is read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so it appears only when the conflict stated it, and `@objectstack/client` reads it as `err.details.currentRecord` on these hosts. Status, `code` and `message` are unchanged, and no other answer changes. +`currentRecord` is the row as the caller reads it. A field the caller's field-level rules hide is absent from it, and a row the caller's row-level rules hide answers `404 RECORD_NOT_FOUND` with neither field, as the REST server answers. It is read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so it appears only when the conflict stated it, and `@objectstack/client` reads it as `err.details.currentRecord` on these hosts. On a `HonoHttpServer` route, status, `code` and `message` are unchanged; on `createHonoApp` they are the `409` the `@objectstack/hono` entry for #23365 states. No other answer changes. Nothing changes in `@objectstack/hono`'s own code: `createHonoApp` renders through `declaredEnvelopeForThrow` from `@objectstack/plugin-hono-server`. diff --git a/.changeset/23546-trail-doors-setup-access.md b/.changeset/23546-trail-doors-setup-access.md index 335cc81f67f..e9b85f37d4c 100644 --- a/.changeset/23546-trail-doors-setup-access.md +++ b/.changeset/23546-trail-doors-setup-access.md @@ -11,7 +11,7 @@ Clause-②: yes (narrowing) **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. -Organization admins hold `setup.access`. Under a wall (the `isolated` and `group` postures), these four doors handed every organization's admin the environment-wide authoring trail of items no organization authors (ADR-0131 C5): who saved each item and when, its superseded bodies, and every package's commit timeline. The data door on the same three tables (`sys_metadata_history`, `sys_metadata_audit`, `sys_metadata_commit`) already refused them (`requiredPermissions: ['studio.access']`, ADR-0131 D7). The doors now follow the same rule. The narrowing applies in every posture, `single` included. +Organization admins hold `setup.access`. Under a wall (the `isolated` and `group` postures), these four doors handed every organization's admin the environment-wide authoring trail of items no organization authors (ADR-0131 C5): who saved each item and when, its superseded bodies, and every package's commit timeline. The data door on the same three tables (`sys_metadata_history`, `sys_metadata_audit`, `sys_metadata_commit`) refuses them (`requiredPermissions: ['studio.access']`, ADR-0131 D7), and the doors now follow the same rule. The narrowing applies in every posture, `single` included. Each door, and the callers it now refuses: From 45a2c8c11ab662db758ed440e2f604de9f983c71 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:54:02 +0000 Subject: [PATCH 4/4] docs(changeset): the Hono structured-refusal note names each door's last-published answer and the duplicate insert's translation Its FROM and "unchanged" sentence now say what each door answered in the last published prerelease: a HonoHttpServer route answered code and message, createHonoApp a 500. Its duplicate-insert example now says what ships: that refusal is answered UNIQUE_VIOLATION, so the classification agrees and its fields ride details. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .changeset/23587-hono-structured-arm-fields.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/23587-hono-structured-arm-fields.md b/.changeset/23587-hono-structured-arm-fields.md index 7ac6bf538dc..58a87c6ee02 100644 --- a/.changeset/23587-hono-structured-arm-fields.md +++ b/.changeset/23587-hono-structured-arm-fields.md @@ -4,12 +4,12 @@ fix(plugin-hono-server): a structured refusal answered by either Hono door carries the fields the standalone REST server's answer carries, such as `409 DELETE_RESTRICTED`'s `developerMessage`, `dependentObject` and `dependentCount` (#23587) -Deleting a record that a `deleteBehavior: 'restrict'` lookup still references is refused by the engine with `409 DELETE_RESTRICTED`. The standalone REST server's answer carries the engine's `developerMessage` (the API names and the `deleteBehavior: 'cascade'` remedy), `dependentObject` and, when the caller may see it, `dependentCount`. The two Hono doors answered the same refusal with `code` and `message` only. They are `@objectstack/hono`'s `createHonoApp` and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`. +Deleting a record that a `deleteBehavior: 'restrict'` lookup still references is refused by the engine with `409 DELETE_RESTRICTED`. The standalone REST server's answer carries the engine's `developerMessage` (the API names and the `deleteBehavior: 'cascade'` remedy), `dependentObject` and, when the caller may see it, `dependentCount`. The two Hono doors answered the same refusal without them. They are `@objectstack/hono`'s `createHonoApp`, which answered it `500 INTERNAL_ERROR` (the `@objectstack/hono` entry for #23365 makes it a `409`), and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`, which answered it with `code` and `message` only. -- FROM `{"success":false,"error":{"code":"DELETE_RESTRICTED","message":"…"}}` +- FROM `{"success":false,"error":{"code":"DELETE_RESTRICTED","message":"…"}}` on a `HonoHttpServer` route, and `500` `{"success":false,"error":{"code":"INTERNAL_ERROR","message":"…"}}` on `createHonoApp` - TO `{"success":false,"error":{"code":"DELETE_RESTRICTED","message":"…","details":{"developerMessage":"…","dependentObject":"…","dependentCount":1}}}` -The same rule now covers every refusal the REST server answers with structured fields, not only `409 CONCURRENT_UPDATE`'s `currentVersion`: `400 INVALID_FIELD` carries `field` and `object`, `404 OBJECT_NOT_FOUND` carries `object`, the `403` capability and access refusals (`FEEDS_DISABLED`, `FILES_DISABLED`, `ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE`) carry `object`, and a `409 METADATA_CONFLICT` thrown out of a direct-mount route carries `currentVersion` (on `createHonoApp` the `/meta` save is answered by the dispatcher itself, which this does not change). The fields ride `error.details`, where `@objectstack/client` reads a wrapped body's context, so `err.details.developerMessage` and its siblings carry them on these hosts too. They are read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so each appears only when the producer stated it. Status, `code` and `message` are unchanged, and a refusal whose classification answers a different status or code (a duplicate insert's `DUPLICATE_RECORD`, which the REST server answers as `UNIQUE_VIOLATION`) gains nothing. +The same rule now covers every refusal the REST server answers with structured fields, not only `409 CONCURRENT_UPDATE`'s `currentVersion`: `400 INVALID_FIELD` carries `field` and `object`, `404 OBJECT_NOT_FOUND` carries `object`, the `403` capability and access refusals (`FEEDS_DISABLED`, `FILES_DISABLED`, `ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE`) carry `object`, and a `409 METADATA_CONFLICT` thrown out of a direct-mount route carries `currentVersion` (on `createHonoApp` the `/meta` save is answered by the dispatcher itself, which this does not change). The fields ride `error.details`, where `@objectstack/client` reads a wrapped body's context, so `err.details.developerMessage` and its siblings carry them on these hosts too. They are read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so each appears only when the producer stated it. On a `HonoHttpServer` route, status, `code` and `message` are unchanged; on `createHonoApp` they are the declared envelope the `@objectstack/hono` entry for #23365 states. A refusal whose classification answers a different status or code gains nothing. The engine's duplicate insert is the one envelope these doors translate first: its `DUPLICATE_RECORD` is answered `409 UNIQUE_VIOLATION`, the REST server's code, so the classification agrees and its fields ride `details`, as this package's `UNIQUE_VIOLATION` entry states. One field stays off these doors: `object` on `DELETE_RESTRICTED`, `CONCURRENT_UPDATE` and `METADATA_CONFLICT` is the object the request's route named, and these doors answer without a route. The REST server's answer is unchanged.