Skip to content

Commit 20cf207

Browse files
committed
Merge commit '1157e7b7218e477e266b4cb7c37042660638e8cf' into claude/issue-15037-remote-loader-list-nameless
2 parents 84f7506 + 1157e7b commit 20cf207

21 files changed

Lines changed: 1411 additions & 53 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/metadata": patch
3+
---
4+
5+
`HistoryCleanupManager` computes its retention cutoff on one calendar, not two.
6+
7+
Both call sites — the age-based delete in `runCleanup()` and the preview count in `getCleanupStats()` — built the cutoff with `cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays)` and then rendered it with `toISOString()`. `setDate`/`getDate` read and write the **local** calendar; `toISOString()` renders **UTC**. They now use `setUTCDate`/`getUTCDate`, so the arithmetic and the rendering agree.
8+
9+
`setDate` preserves wall-clock time, so shifting the local calendar back `n` days moves the *instant* by exactly `n × 24h` only while every local day in the window is 24 hours long. When the window straddles a DST transition it is 23 hours (spring-forward) or 25 (fall-back), and the cutoff instant that goes into the `recorded_at: { $lt: … }` **delete** filter is off by the size of that transition — one hour in most zones, thirty minutes on Lord Howe Island. History rows within that slip of the retention boundary were deleted early, or retained too long.
10+
11+
The exposure is not limited to the two transition days: the window only has to *straddle* a transition, so it grows with `maxAgeDays`. Measured over a 12-zone × 366-day × 48-half-hour sweep of 2026, in `America/New_York` the old spelling produced a wrong cutoff for 0.6% of instants at `maxAgeDays: 1`, 16.4% at 30, 49.7% at 90 and 69.4% at 180. In zones that do not observe DST (`UTC`, `Asia/Shanghai`, `Asia/Kolkata`, `Australia/Perth`) the rate is 0.0% at every `maxAgeDays` — which is why no test had ever gone red on this.
12+
13+
This does **not** make retention timezone-aware, and does not change what `maxAgeDays` means. The cutoff was already intended to be `now − maxAgeDays × 24h`; it is now that in every zone rather than only in zones without DST. Nothing else in either filter moved: the `organization_id` scoping, the ADR-0009 `executionPinned` exclusion and the `maxVersions` path are untouched.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/hono': patch
3+
---
4+
5+
The Hono adapter's `/auth/*` mount yields only a 404 that disclaims ownership
6+
7+
`createHonoApp`'s `${prefix}/auth/*` mount forwards every request under it to the
8+
kernel's `auth` service and, since #4117, hands the request on to the rest of the
9+
chain when that service answers 404 — which is what keeps `/auth/me/permissions`
10+
and `/auth/me/localization` reachable through the gated `dispatch()`. The yield
11+
had only the status to go on, so it could not tell "I do not serve this path"
12+
from "I serve it and the answer is 404".
13+
14+
Measured on a real boot through this adapter (a real kernel with `AuthPlugin`,
15+
`prefix: '/api/v1'`), `GET /api/v1/auth/delete-user/callback?token=…&callbackURL=…`
16+
answered `404 {"message":"Not found","code":"NOT_FOUND"}` from better-auth and
17+
`200 {}` on the wire. `plugin-auth`'s route ledger carries that route under its
18+
`disabled` disposition precisely because it is published and answers 404, so the
19+
ledger's recorded answer was true of the auth service and false on this adapter's
20+
wire. Nothing had to be composed in for that: the `${prefix}/*` dispatcher
21+
catch-all this same function registers is terminal and answers `200 {}` for paths
22+
under `/auth/`.
23+
24+
The mount now asks the auth service whether its own router serves the path, via
25+
an optional `ownsRoute(request)` — the seam `AuthManager` grew in the plugin-side
26+
fix for the same defect — and yields only when it does not. Every answer that is
27+
not a literal `true` (no such method, a throw, anything else) means yield, so a
28+
service predating the method behaves exactly as before and a failure to decide
29+
can never cost the ordering-independent surface.
30+
31+
⛔ The mount is unchanged and still claims `${prefix}/auth/*`; 401/403 were never
32+
yielded and still are not. What narrowed is only which 404 may be handed on.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os migrate meta` no longer prints the protocol version under the word "runtime", where it read as the installed package version.
6+
7+
The chain line used to end `(runtime 17.0.0)`. That number is `PROTOCOL_VERSION` — the protocol major padded to a semver — and it is not, and never tracks, the version of the installed `@objectstack/cli` or `@objectstack/spec`. On a 17.3.0 install the line appeared beside the real package versions of the same upgrade session (`npm view`, the changelog), so it read as "your runtime is 17.0.0": an apparent downgrade or a stale install, neither of which was true.
8+
9+
The value was never wrong — the label and the semver form were. The line now states the fact in the protocol's own units:
10+
11+
```
12+
Chain: protocol 17 → 17 (this runtime implements protocol 17)
13+
```
14+
15+
The parenthetical is relabelled rather than dropped, because it carries a fact nothing else on screen does: when `--to` stops below this build's major, it is the only place the operator is told where the runtime actually stands (`Chain: protocol 16 → 16 (this runtime implements protocol 17)`).
16+
17+
The `--json` payload is deliberately untouched: its `runtime` key still carries the same padded protocol semver. Renaming a machine-readable key is a contract change owing a reader census and a deprecation window of its own, and it is tracked separately — an e2e pin now asserts the key's current value so that move cannot happen silently.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-sharing": patch
4+
"@objectstack/runtime": patch
5+
---
6+
7+
`publicSharing.enabled` now has one canonical predicate, exported from the package that declares the key.
8+
9+
`isPublicSharingEnabled(schema)` is a new export of `@objectstack/spec/data`, declared in `src/data/object.zod.ts` beside the `publicSharing` block itself — the same shape as the neighbouring `isTenancyDisabled`. It is additive: nothing was removed or narrowed from the spec's public API.
10+
11+
Until now the same policy read existed in two spellings. `@objectstack/plugin-sharing` defined it (for the share-link service's redemption gate and the route probe above it), and `@objectstack/runtime` carried a documented private mirror for its `/share-links` dispatcher domain — copied rather than imported because the plugin is only a **dev** dependency of the runtime. That reasoning was true of that one home and not of the question: both packages already depend on `@objectstack/spec`, so a shared home existed all along and the de-duplication adds no dependency edge. Both surfaces now consume the exported predicate and the runtime copy is deleted.
12+
13+
Behaviour is unchanged, fail-closed included: an absent `publicSharing` block, an absent schema, and an engine that cannot answer `getSchema` at all remain **one** answer, `false`, and only the boolean `true` enables. The two pins that held the copies equal — `share-link-eligibility.test.ts` in the plugin and `share-links-enforcement-context.test.ts` in the runtime, which assert the same observable answer on both surfaces rather than trusting the copy — are unchanged and still green; they are what proves the merge did not move behaviour. The predicate's own contract, which those tests can only observe indirectly, is now pinned directly in `packages/spec/src/data/object.test.ts`.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(service-automation): evict a suspension consumed by another replica, so the run listings stop reporting phantoms (#15832)
6+
7+
`AutomationEngine` had exactly one eviction site for its `suspendedRuns`
8+
map, inside `forgetSuspendedRun` — and that runs in whichever process
9+
**consumes** the suspension. In a multi-replica deployment that is routinely
10+
not the process that parked it: replica A parks a run, replica B resumes it,
11+
and nothing ever removes A's entry. There is no invalidation channel from B
12+
to A.
13+
14+
The card that found this located the leak on `resumeInternal`'s
15+
`claim.kind === 'lost'` branch, which returns before that choke point. That
16+
branch does leak, but it is not the common shape: the **no-race** variant
17+
leaks identically — A parks, only B ever resumes, A never attempts a claim
18+
and there is no `'lost'` anywhere in the sequence — so an eviction hung on
19+
`'lost'` alone would have left the ordinary deployment untouched.
20+
21+
The retained snapshot was **not only memory**. Two readers handed it back:
22+
`listSuspendedRuns()` (synchronous, cache-only, and the one listing on the
23+
`AutomationService` spec contract) and `listSuspendedRunsDurable()` (which
24+
deliberately appends map entries the durable list lacks). Once the other
25+
replica **completed** the run, both reported a phantom — a finished run
26+
listed as suspended, whose `getSuspendedScreen()` answers `null`, so a
27+
consumer that listed and then opened got an entry it could not act on.
28+
29+
An entry is now dropped whenever this process holds a store-authoritative,
30+
per-id "no row" answer for it: the strict loader's store miss (which reaches
31+
`resume`, `hasSuspendedRun`, `cancelRun` and `getSuspendedScreen`), a lost
32+
advance claim, and a bounded per-id reconcile for the map-only entries of
33+
`listSuspendedRunsDurable()`.
34+
35+
**Nothing here moves the cache-only listing's contract.** The fix only ever
36+
*removes* entries. The spec says `listSuspendedRuns()` lists "the currently
37+
suspended (paused) runs awaiting a resume"; the engine's own docblock adds
38+
only that it may OMIT runs (those parked in a previous process lifetime),
39+
because it reads the cache alone. Under-reporting is therefore already
40+
inside the declared latitude, and over-reporting was never inside the
41+
promise. Neither listing becomes store-backed, and `listSuspendedRuns()`
42+
stays synchronous.
43+
44+
Three shapes are deliberately **never** evicted, each pinned by a control:
45+
no store attached (the map IS the authority); a run whose durable save
46+
failed (`cacheOnlySuspensions` — the store was never handed the row, so its
47+
silence says nothing about it); and a store read that THROWS (an outage
48+
means the run's existence is unknown, not gone). A failed `list()`
49+
enumeration likewise triggers no per-id reconcile — during an outage that
50+
would ask about every live run in the process.
51+
52+
**Residual, stated rather than implied.** Eviction is demand-driven: a
53+
phantom is cleared when this process next obtains the per-id answer for that
54+
run — any `resume` / `hasSuspendedRun` / `getSuspendedScreen`, or a
55+
`listSuspendedRunsDurable()` reconcile. A process that never looks at the
56+
run again keeps the entry until it does. With no invalidation channel
57+
between replicas, closing that last gap needs either a background sweep or a
58+
store-backed listing, and both are decisions above this change; the boundary
59+
is pinned by a `RESIDUAL` test rather than left to be discovered.
60+
61+
Note 2 of the same card — the `'unsupported'` branch deciding on the shape of
62+
a value the conditional delete has **already** been issued to obtain — is
63+
**not** addressed here: its honest fix is a declared return contract for the
64+
engine's multi-row delete, which lands in another package.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ The largest single consumer — **17 of the 105 sites**.
135135
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1476` (guard at `:1501`) |
136136
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1528` |
137137
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1189` |
138-
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:469`, `:523`, `:527`, `:600`, `:630` |
138+
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:459`, `:513`, `:517`, `:590`, `:620` |
139139
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
140140
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:278`, `:503` |
141141

0 commit comments

Comments
 (0)