Skip to content

Commit 3c97f71

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-19939-pass3-date-macros
2 parents 0593be8 + e223152 commit 3c97f71

36 files changed

Lines changed: 2285 additions & 1746 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): the protocol upgrade guide has a public address, one docs-site page per protocol major
6+
7+
Clause-②: no
8+
9+
The guide is published on the docs site, one page per protocol major, at `https://objectstack.ai/docs/protocol-upgrade/N`; `https://objectstack.ai/docs/protocol-upgrade` lists them all. A link to one major keeps resolving after the next major opens. The site builds from `main`, so each page says whether its major is released, in prerelease, or not released yet.
10+
11+
`docs/protocol-upgrade-guide.md` in the repository, which earlier READMEs and changelog entries point at, is now a short pointer naming that address.
12+
13+
What a release shipped is unchanged: `protocol-upgrade-guide.md` inside the package and attached to its GitHub Release. Its only change is the header comment, which now names the command that writes the file.
14+
15+
Nothing changes for an author, and nothing is renamed or removed.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
feat(spec)!: `PageSchema` refuses a page that authors both `slots.details` and `slots.tabs`; the `sys_user` record page carries its details grid as its first tab
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: registered page-slots-details-beside-tabs-refused -->
11+
12+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.
13+
14+
**Why.** The slot map read as seven independent slots, and two of them are not. On a slotted record page `details` replaces the body of the Details tab, that tab lives inside the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console's default-page synthesizer therefore reads `tabs` and never reads `details` when both are authored. A page authoring both passed `PageSchema.parse`, `objectstack validate` and the metadata save door, and its details body (its `sections`, its `hideFields`) silently never applied. The platform's own `sys_user` record page was one: its Identity and Audit sections never showed, and the ban columns it hides were never hidden by it.
15+
16+
**What is refused.** A page whose `slots` map carries both a `details` and a `tabs` key, at `slots.details`, with a `custom` issue that names both slots and the fix. Either slot may be one component or an array; an empty `details: []` is refused like a full one, and the page's `kind` does not matter. That covers `PageSchema`, `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `os validate` and the metadata save door (`422 INVALID_METADATA`). The check is exported as `checkPageSlotPair`, for a mirror built from `PageSchema.shape` to re-attach.
17+
18+
**What is still accepted, byte for byte.** A page authoring only `details`, only `tabs`, or neither.
19+
20+
## FROM → TO
21+
22+
| you wrote | write instead |
23+
|:--|:--|
24+
| `slots: { details: { type: 'record:details', … }, tabs: { type: 'page:tabs', properties: { items: [ … ] } } }` | `slots: { tabs: { type: 'page:tabs', properties: { items: [ { label: 'Details', children: [ { type: 'record:details', … } ] }, … ] } } }` |
25+
| both slots, wanting the synthesized tabs with your details body | `slots: { details: { type: 'record:details', … } }`, with no `tabs` slot |
26+
27+
**The one-line fix: move the `record:details` component into the `tabs` items (the first item's `children`, by convention) and delete `slots.details`.** Its `sections` and `hideFields` move unchanged. The page then shows the details body it always declared, which is a visible change on every page that authored the pair.
28+
29+
**Who is affected, measured.** In this repository, only the `sys_user` record page (`sys_user_detail` in `@objectstack/platform-objects`) authored both slots; no example app page does. hotcrm's one slotted page authors `header` and `discussion` only. Deployed metadata was not measured. A page row already stored with the pair is replayed unchanged at load, so it renders as before (the authored tabs, without the details body); its read diagnostics name the pair, and saving it again is refused until the details body moves.
30+
31+
### The kit
32+
33+
- **The refusal.** `checkPageSlotPair`, attached to `PageSchema` by identifier beside its three other object-level checks. No new error code.
34+
- **The producer.** `sys_user_detail` moves its `record:details`, with its `sections` and `hideFields` unchanged, in as the first `tabs` item.
35+
- **The ledger.** The D3 semantic entry `page-slots-details-beside-tabs-refused` (protocol 18) and its step-18 rationale fragment. No key is removed, so there is no tombstone, and there is no D2 conversion: which tab item carries the details body, and under which label, is the author's decision.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
`security/explain` answers a transfer of a row the caller cannot read the way the transfer door does: as a record that does not exist.
6+
7+
- **What was wrong.** On the write doors, a row the caller cannot read answers what a nonexistent id answers: the by-id update or delete the caller addresses gets `404 RECORD_NOT_FOUND`. A transfer's door is the `PATCH` that writes `owner_id`, an update, so a transfer of an unreadable row gets that 404 too. explain's matching rewrite (the missing-record shape, `record: { visible: false }` with no `decidedBy`) keyed on the verb as asked, `update` or `delete`, so it never ran for `transfer`. Where the caller's update policy admitted the hidden row, explain answered `visible: true`, decided by `rls`. Where the caller declared no write policy, it answered `visible: false` decided by `rls`, which is the shape of a 403 refusal, not of the door's 404.
8+
- **What it does now.** The rewrite asks which operation the verb's door is through the same mapping the by-id write path reads (`transfer` and `restore` as update, `purge` as delete). So every write verb whose door is a by-id update or delete is asked the read question, and a transfer of a row the caller cannot read is `{ visible: false }` with no decider, beside the door's 404.
9+
- **What is unchanged.** `update` and `delete` are answered exactly as before. `restore` and `purge` are still refused at the object gate for every principal, which answers first. A readable row keeps its verdict for every verb. The object-level `allowed` answers the object question and is not touched. No door changes, and no export is added to the package.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): the approvals service contract declares an optional, transport-neutral action-page member (`IApprovalService.handleActionPage`)
6+
7+
Clause-②: yes (widening)
8+
9+
- **What is new.** `IApprovalService` in `@objectstack/spec/contracts` gains one optional member, `handleActionPage?(request: Request): Promise<Response>`. It serves the ADR-0043 action page, the session-less page an approver reaches from the approve or reject link in an e-mail or IM message. `GET` renders the confirm page from the `token` query parameter and never decides. `POST` redeems the `token` form field and answers the result page.
10+
- **The token is the only credential.** The member takes no `ExecutionContext` and reads no session, cookie or `Authorization` header. The decision is audited as the approver the token binds.
11+
- **Transport-neutral.** A web-standard `Request` in, a `Response` out: no Hono context, no raw app and no `http.server` type. A hosted tenant kernel has no raw app to mount pages on, and the runtime dispatcher's `/approvals/act` domain is the member's one caller.
12+
- **Absence.** The member is optional, so an approvals implementation without action pages still satisfies the contract. Its caller answers a typed 404 or 501 when the `approvals` service or this member is absent, never `ROUTE_NOT_FOUND`.
13+
- **Nothing changes for existing implementers.** No existing member moved. Nothing implements or calls the member yet: the plugin implementation and the dispatcher domain land separately, and the plugin's self-hosted pages stay as they are.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
feat(core): the ADR-0069 auth-gate allow-list admits the ADR-0043 approval action route, `approvals/act`, as one exact route
6+
7+
Clause-②: yes
8+
9+
- **What the allow-list admits now.** `isAuthGateAllowlisted` answers `true` for the approval action route `approvals/act`, at the mount bases the gate already recognises (`/api/v1/approvals/act`, `/api/approvals/act`, and the dispatcher's prefix-stripped `/approvals/act`), unscoped or under one `/environments/<id>` (or legacy `/projects/<id>`) scope, with the query string and trailing slashes stripped as for every other entry. Nothing else moves: `/approvals/act/<anything>`, `/approvals/actx`, the approvals base and every `/approvals/requests/…` route stay gated.
10+
- **Why this is not a loosening.** ADR-0043 authenticates the action page by its single-use token alone, and `plugin-approvals`' self-hosted mount (`GET` renders, `POST` redeems on the host app) has never passed through this gate. The entry gives the dispatcher's spelling of the same route the same token-only authentication, so a session held by an authentication policy (expired password, required MFA) is no longer refused there with that policy's `403` before the token is read.
11+
- **Effect on a kernel whose dispatcher serves no `/approvals/act` domain.** A held session that reaches the route gets the dispatcher's `404 ROUTE_NOT_FOUND`, the answer every other caller already gets there, instead of the policy's `403`. Nothing on the REST surface is affected: no REST route serves `approvals/act`, and no shipped seam passes a path to `shouldDenyAnonymous`.

‎.gitattributes‎

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,37 @@
1717
# stay routed here as directories: the driver still owns a same-category
1818
# collision, which is the residue sharding cannot remove.
1919
#
20-
# Two entries below are still SINGLE files — spec-changes.json and
21-
# docs/protocol-upgrade-guide.md — and #8344 asked what the driver-less queue does
22-
# to them. MEASURED (2026-08-13, the real in-flight case plus four synthetic pairs,
23-
# each merged in a clone with no merge.os-regen.driver): it never leaves them
24-
# stale-but-clean. Both are sorted unions and an ADR-0087 registration is
25-
# insertion-only, so the queue's text merge either takes both sides — byte-identical
26-
# to the regeneration, gates green — or conflicts outright. It conflicts only when
27-
# the two in-flight entries are ADJACENT in registry sort order; one existing entry
28-
# between them is already enough to merge clean AND current.
29-
#
30-
# Sharding them would not buy back an ejection, which is why they are still single
31-
# files: every conflicting case also conflicts in
20+
# One entry below is still a SINGLE file — spec-changes.json — and #8344 asked
21+
# what the driver-less queue does to it. MEASURED (2026-08-13, the real in-flight
22+
# case plus four synthetic pairs, each merged in a clone with no
23+
# merge.os-regen.driver, over it and the protocol upgrade guide, then a second
24+
# committed single file): the queue leaves neither stale-but-clean. Both were sorted
25+
# unions and an ADR-0087 registration is insertion-only, so the queue's text merge
26+
# either takes both sides — byte-identical to the regeneration, gates green — or
27+
# conflicts outright. It conflicts only when the two in-flight entries are ADJACENT
28+
# in registry sort order; one existing entry between them is already enough to
29+
# merge clean AND current.
30+
#
31+
# Sharding it would not buy back an ejection, which is why it is still a single
32+
# file: every conflicting case also conflicts in
3233
# packages/spec/src/migrations/registry.ts — generated, committed, unsharded,
3334
# NOT_DRIVER_MANAGED — and every registration touches it by construction. The table
3435
# and the reproduction are in packages/spec/src/migrations/entries/README.md.
3536
#
37+
# docs/protocol-upgrade-guide.md LEFT this list at #22483 because nothing generates
38+
# it any more. It is a hand-written pointer stub naming the guide's public address,
39+
# and `gen:upgrade-guide` writes the gitignored docs pages under
40+
# content/docs/protocol-upgrade/ instead (NOT_DRIVER_MANAGED records that path). A
41+
# route there was harmful, not idle: the driver kept OURS whole, and the pre-commit
42+
# discharge then passed it, because `check:upgrade-guide` compares no committed
43+
# copy — so a branch carrying a generated guide merged the stub away in silence.
44+
# Unrouted, a merge made from a checkout that carries this file conflicts on the
45+
# stub. ⚠️ git reads the route from the checkout DOING the merge, so a branch
46+
# forked before #22483 still defers the guide on its first merge of main; the merged
47+
# tree has no row to discharge it against, so the next commit and the push are
48+
# refused instead of passed (measured on PR #22556 with the previous head as the
49+
# control, both merge directions).
50+
#
3651
# `merge=os-regen` hands those paths to `scripts/git-merge-regen.mjs`, which does
3752
# NOT text-merge them. See that file for why it also does not regenerate them
3853
# in place (git runs merge drivers BEFORE the sources are merged, so anything
@@ -173,7 +188,6 @@ packages/spec/src/meta-spelling/meta-url-data.generated.ts merge=os-regen
173188
packages/spec/export-origins/** merge=os-regen
174189
packages/spec/declaration-map/** merge=os-regen
175190
packages/spec/api-surface-signatures.json merge=os-regen
176-
docs/protocol-upgrade-guide.md merge=os-regen
177191
docs/audits/2026-07-unknown-key-strictness-ledger.counts/** merge=os-regen
178192
content/docs/references/** merge=os-regen
179193
content/docs/permissions/system-context.mdx merge=os-regen

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ next-env.d.ts
103103
.source/
104104
apps/*/. source/
105105
apps/*/.next/docs/
106+
# The protocol upgrade guide's docs pages: generated at docs build by
107+
# `pnpm --filter @objectstack/spec gen:upgrade-guide`, never committed.
108+
content/docs/protocol-upgrade/
106109
.turbo
107110

108111
# Local env overrides (may contain secrets)

‎apps/docs/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"license": "Apache-2.0",
77
"scripts": {
88
"dev": "next dev",
9-
"build": "pnpm --filter @objectstack/spec gen:schema && pnpm --filter @objectstack/spec gen:docs && NODE_OPTIONS='--max-old-space-size=4096' next build",
9+
"build": "pnpm --filter @objectstack/spec gen:schema && pnpm --filter @objectstack/spec gen:docs && pnpm --filter @objectstack/spec gen:upgrade-guide && NODE_OPTIONS='--max-old-space-size=4096' next build",
1010
"start": "next start",
1111
"site:lint": "next lint",
1212
"postinstall": "fumadocs-mdx",

‎content/docs/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"ai",
1515
"api",
1616
"upgrading",
17+
"protocol-upgrade",
1718
"---Platform---",
1819
"deployment",
1920
"plugins",

0 commit comments

Comments
 (0)