Skip to content

Commit 590c4b7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14399-view-container-name-order
2 parents 860f186 + ca48cf3 commit 590c4b7

71 files changed

Lines changed: 2698 additions & 683 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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
docs(core): the `AuthzStoreUnavailableError` brand doc states the measured `structuredClone` behaviour instead of claiming survival (#14006)
6+
7+
Documentation only — no runtime change, no type change, no accept/reject
8+
behaviour moves. It ships as a patch because the docblock is a **published
9+
byte**: `tsup`'s declaration rollup carries it into `dist/index.d.ts` and
10+
`dist/index.d.cts`, so it is what a consumer reads on hover.
11+
12+
The brand's docblock justified the string-keyed own property with two reasons
13+
joined by an `and`, of which only the second was true:
14+
15+
> A string-keyed own property (not a `Symbol.for` registry key) so it survives
16+
> `structuredClone`, and so a duplicated copy of this module still brands
17+
> identically.
18+
19+
Measured on Node 22.22.2: the structured-clone algorithm gives `Error` a
20+
dedicated serialization carrying `message`, `stack` and `cause` only, and drops
21+
every other own property — the brand, the ADR-0112 `code`, `status` and
22+
`object` alike (a subclass's own `name` returns as `'Error'`). The
23+
plain-object control is the half that proves it: `{ __brand: true, code: 'C' }`
24+
keeps **both** keys through the same call, so the loss is specific to `Error`,
25+
not general to `structuredClone`.
26+
27+
The property and the reason that actually earns it are kept — a duplicated copy
28+
of the module still brands identically, which is exactly what `instanceof`
29+
cannot do across two installed copies of `@objectstack/core`. The false half is
30+
replaced by the measured behaviour, carrying the reproducible script and the
31+
Node version rather than a second unsourced assertion, and phrased to match
32+
what `service-not-registered.ts` already records for its own brand (one
33+
phrasing across the two modules, not two).
34+
35+
⛔ The clone gap is deliberately NOT "fixed" with a `toJSON` or a custom
36+
serialization: no call site crosses a clone boundary today
37+
(`rethrowAuthzStoreUnavailable` on the rest rethrow paths,
38+
`isAuthzStoreUnavailableError` inside service `catch` blocks — all in-process),
39+
and adding one would widen the module's surface with nothing pulling on it. The
40+
docblock instead names the trap the false claim invited: branching on the brand
41+
across a worker or `postMessage` boundary would answer `false` and fail OPEN on
42+
a security path.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
feat(core): `Plugin.type` is the closed set the spec declares — a `PluginType` derived from `CORE_PLUGIN_TYPES` (#13925)
6+
7+
**BREAKING** accept-set narrowing on a published type, shipped as `minor`
8+
under the repo's launch-window convention for breaking changes. `Plugin.type`
9+
(and, through it, `PluginMetadata.type`) was declared `string`, so nothing
10+
type-checked a plugin author against the eight values the platform accepts —
11+
the TSDoc beside it carried the whole enumeration as prose, and prose drifted.
12+
Maintainer ruling 2026-09-01: the Zod enum in `@objectstack/spec`
13+
(`PluginSchema.type`, declared `z.enum(['standard', ...CORE_PLUGIN_TYPES])`)
14+
is the authority and the contract was always a closed set; the `string` in
15+
core was the mismatch, and narrowing it is core aligning to the declared
16+
contract rather than a new restriction. Paid in one stroke — no warning window.
17+
18+
What changes:
19+
20+
- `@objectstack/core` now exports `PluginType`, derived from the spec's own
21+
constant: `'standard' | (typeof CORE_PLUGIN_TYPES)[number]` — today
22+
`standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`.
23+
It is not re-spelled in core, so the compiler's accept set and the Zod gate's
24+
cannot drift apart; a runtime parity test pins the two against each other.
25+
- `Plugin.type` is typed `PluginType`. A literal outside the set, or a value
26+
typed `string`, no longer compiles. Runtime behaviour is unchanged: the Zod
27+
gate refused such a value before and still does (`invalid_value` at `type`).
28+
29+
**Migration.** A plugin that declares one of the eight members needs no change.
30+
A plugin that assigned a computed or `string`-typed value narrows it at the
31+
producer — declare the literal, or type the variable `PluginType` — rather than
32+
casting at the assignment; a value that was never one of the eight was never a
33+
valid plugin type and was already refused at parse time.
34+
35+
<!-- adr-0087: not-required (no-migration-prescription) A TypeScript narrowing on a published runtime interface, aligning `packages/core` to the accept set `packages/spec` already declared. No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed — `CORE_PLUGIN_TYPES` and `PluginSchema.type` are read, not changed — so `objectstack migrate meta` has nothing to rewrite and there is no tombstone to mint. The channel that reaches an affected author is the compiler, at the assignment, which is more precise than a ledger line; which member a formerly `string`-typed value should become is authoring intent no migration entry can decide. The in-repo census under the workspace typecheck is recorded on the PR. -->
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/service-job": patch
3+
---
4+
5+
fix(service-job): leader-elect `type: 'once'` schedules on `DbJobAdapter` (#13918)
6+
7+
`DbJobAdapter.schedule()` decides which adapter owns a scheduled fire, and only
8+
`CronJobAdapter` takes the cluster lock (`runScheduled()` -> `lock.acquire('job:'
9+
+ name, { waitMs: 0 })`). `cron` schedules were routed there from the start and
10+
`interval` schedules since #13686 — but `once` schedules still went to the inner
11+
`IntervalJobAdapter`, a bare `setTimeout` with no lock. On a multi-replica
12+
deployment a one-shot job therefore ran **once per replica**, not once per
13+
cluster, and a one-shot is the worst-shaped of the three: there is no later tick
14+
during which a business-level de-duplication marker could win, so every replica's
15+
copy lands inside the same short window.
16+
17+
`once` now takes the same leader-elected path as `cron` and `interval` whenever a
18+
cron adapter is assembled, and stays registered on the inner adapter via
19+
`register()` (stored, not armed), so `trigger()`, `replay()`, `getExecutions()`
20+
and `listJobs()` answer for it exactly as before. Affected paths in this repo:
21+
the automation wait-node's timer resume and its cold-boot re-arm
22+
(`@objectstack/service-automation`), schedule-triggered flows with an `at`
23+
(`@objectstack/trigger-schedule`), and app-declared jobs with a `once` schedule
24+
(`@objectstack/runtime`).
25+
26+
**Behaviour change, on multi-replica assemblies only.** A `once` job now fires on
27+
one replica per cluster instead of on every replica. Single-node behaviour is
28+
unchanged in both assemblies: with a cron adapter and no cluster driver the lock
29+
is always granted, and with no cron adapter at all the job still fires on the
30+
inner timer exactly as before. The semantics are **at-most-once per cluster**
31+
(maintainer ruling 2026-09-01): election decides who fires, not that the fire
32+
survives — a leader that dies mid-fire loses it, and nothing re-arms it. That
33+
takes nothing away, because the previous unelected `setTimeout` was not persisted
34+
either and the same crash lost it on every replica at once. No re-arm, retry or
35+
persistence mechanism is added.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
Restore the `field` key and the curated conflict sentence on the 409 body for an insert refused by a unique constraint
6+
7+
Since the ObjectQL engine began answering a driver's unique violation with its own `DUPLICATE_RECORD` envelope (`status: 409`, `object`, `field`, the driver error on `cause`), `POST /api/v1/data/:object` let that envelope leave `classifyDataError` through the generic declared-status passthrough: still 409, but with `code: 'DUPLICATE_RECORD'`, no `field`, and the engine's own sentence in `error`. The `isUniqueViolationError` arm that names the conflicting column (`field: 'email'`, "A record with this email already exists") was no longer reached for an insert conflict. Measured with the real engine and real drivers: on `driver-sqlite-wasm` the `field` key disappeared from every conflict on a single-column index; on `driver-memory` the sentence changed.
8+
9+
A dedicated arm for the engine's envelope now sits with the other structured 409s (`DELETE_RESTRICTED`, `CONCURRENT_UPDATE`), ahead of the passthrough:
10+
11+
- `code` stays `UNIQUE_VIOLATION` — the code every client branching on this conflict already reads.
12+
- `field` is restored whenever the dialect determinably named the column (SQLite, Postgres); composite keys and index-naming dialects (MySQL) carry no `field` key, exactly as before.
13+
- `error` is the curated end-user sentence again; the engine's own sentence rides on `developerMessage`, the same split the `DELETE_RESTRICTED` body uses.
14+
- The body still quotes nothing the driver said — no offending value, no statement, no index name — including on `driver-memory`, whose raw refusal used to echo the offending values as JSON through the passthrough.
15+
16+
`patch`: a restoration of the shipped body's keys and wording; the wire `code` and the status are unchanged. The arm fires for the engine's envelope only; a plugin or sandbox body that throws the registered `DUPLICATE_RECORD` itself keeps the answer it gets today.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
"@objectstack/plugin-sharing": minor
3+
---
4+
5+
fix(plugin-sharing): hold `publicSharing.enabled` at redemption, not only at mint (#14033)
6+
7+
**BREAKING** runtime behaviour change on a published package: share links that
8+
were legitimately minted can now stop resolving without anyone revoking them —
9+
every link on an object whose `publicSharing.enabled` is not `true`. Shipped as
10+
`minor` under the repo's launch-window convention (a breaking change does not
11+
burn a major while the stack is in lockstep). No export is added, removed or
12+
re-shaped; the level carries the breaking banner, not a surface change.
13+
14+
`ShareLinkService.createLink()` refused to mint on an object whose
15+
`publicSharing` block was absent or had `enabled !== true` (422
16+
`SHARING_NOT_ENABLED`), and nothing checked the switch again. `resolveToken()`
17+
checked `revoked_at`, `expires_at`, the audience gates, the password, record
18+
existence and — since #13608 — the block's `eligibility` predicate, then served
19+
whatever survived, under the system context, to a caller with no principal at
20+
all. So the platform held the block's CHILD predicate as a standing policy while
21+
its PARENT switch governed minting only: an author who turned the whole feature
22+
off stopped new links and not one existing link, and would have had to narrow
23+
the predicate to stop anonymous serving — the opposite of what the surface
24+
reads like. Measured before it was changed: a token minted while the block was
25+
on kept serving the record in full after the block was turned off.
26+
27+
**What changed.** `resolveToken()` reads the object's CURRENT `publicSharing`
28+
block on every redemption and refuses when `enabled` is not `true` — before the
29+
record is read, before the usage stamp, before any sibling key inside the block
30+
is evaluated. Re-enabling the block restores the same tokens: this is a standing
31+
policy, not a revocation, and no `sys_share_link` row is touched. How a link was
32+
minted buys it nothing at redemption — a link minted under a system context or
33+
the service's `permissive` bypass (the system-context ledger's row 37 path) on a
34+
switched-off object refuses exactly like one orphaned by an author turning the
35+
block off, and an object with no `publicSharing` block at all is the same switch
36+
at its default and refuses too. With the block on, `eligibility` (#13608) and
37+
the declared `redactFields` (#13856) keep their existing redemption-time
38+
behaviour; nothing new is evaluated. An object the engine cannot return a
39+
schema for — no `getSchema` on the engine, or an object not registered at the
40+
moment of redemption — is `enabled: false` by `getPolicy`'s definition and is
41+
refused at redemption: fail-closed, the same definition `createLink` uses. The
42+
#13856 entry's "an opted-out object's links keep resolving with the declared
43+
redactions" state is superseded: with the block off they do not resolve at all.
44+
45+
**The refusal is deliberately indistinguishable.** It is the same answer a
46+
revoked, expired, unknown or no-longer-eligible token already gets: the
47+
undifferentiated `null` — no new error code, no new response branch, and no
48+
usage stamp. Over HTTP a switched-off link is answered with the generic
49+
`404 INVALID_OR_EXPIRED`, byte-for-byte what a token that never existed
50+
receives. The readable reason (`SHARING_NOT_ENABLED`, with the link, object and
51+
record ids) is written to the server-side log at `warn`, where the eligibility
52+
refusal already writes its own.
53+
54+
**Operator impact — retroactive, on deploy.** Every live link on an object whose
55+
`publicSharing` block is currently switched off — or that never declared one —
56+
stops resolving the moment this version is deployed, with no revocation event
57+
and no grace period. That is the intent: the alternative is a declared switch
58+
the platform does not hold. Measure before rollout: the objects to read are
59+
those whose `publicSharing.enabled` is not `true`, and the links at risk are the
60+
`sys_share_link` rows naming them (`object_name`). To keep such links working,
61+
enable the block — and narrow it with `eligibility` / `redactFields` if the
62+
feature was off for a reason; there is no per-link opt-out, deliberately.
63+
Minting is unchanged: `createLink` still refuses `SHARING_NOT_ENABLED` for an
64+
ordinary caller, and the system / `permissive` bypass still mints — what it
65+
mints simply does not serve until the block is on. The refusal logs one `warn`
66+
line per refused hit and is not latched, so a retroactive deploy with many live
67+
links on switched-off objects will burst the log once.
68+
69+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.enabled` keeps its name, its type, its default and its accept-set, and the change is WHEN the platform holds it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether the block should be on at all, which is an authoring decision no ledger entry can make on its behalf. -->

.claude/skills/checklist-author/SKILL.md

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,27 +28,18 @@ metadata:
2828
`node scripts/check-platform-checklist.mjs` 下校验为绿,并按 AGENTS.md
2929
(worktree-first,PD#11)落在任务分支上。
3030

31-
## 编排契约
32-
33-
1. **Worktree 先行**(PD#11):`git fetch origin main && git worktree add --no-track ../objectstack-<task>
34-
-b <branch> origin/main`。全部编辑都在那里做。派发任何 agent 之前先读清单现状。
35-
2. **五个只读 gap hunter 并行** —— 每个 SWEEP.md 角度一个(console UI / spec 枚举 /
36-
路由与运行时 / 内置应用 / 文档声称)。每个拿到:当前 item-id 清单、已知的 waiver
37-
与 blocked 项(不重复上报),以及输出契约 `surface | evidence path | coverage
38-
verdict | proposed id | sketch | fixture?`。hunter 不写任何文件。
39-
3. **去重并入一个草稿登记表**(落地前删掉)。跨角度的重复命中是高优先级信号,不是噪
40-
音。
41-
4. **按区 writer agent** —— 每个 `areas/*.json` 文件一个 agent,writer 之间永不相
42-
撞;除编排者外谁都不碰 `coverage.json``scripts/`。每个测试项都遵守 README.md
43-
的 deep-test 契约;缺 fixture 就记 `blocked`/`knownGaps`,永不伪造覆盖。writer
44-
断言之前把每个 endpoint、枚举、错误码都对到源码上 —— 把本技能的简报当假设,源码
45-
才是真相。
46-
5. **集中对账**:hunter 证明有现成 fixture 的种类一律解除 waiver(2026-08 那轮
47-
sweep 里六条 waiver 有四条已过期 —— 每次都重审全部 waiver),新项映射进
48-
`coverage.json`,新的 variants 矩阵钉上 `enumSource`(见 README「Variants stay
49-
fresh automatically」)。
50-
6. **校验 + 落地**:校验器绿,再提交到任务分支。产品缺陷与文档漂移进
51-
`FOLLOW-UPS.md`;安全敏感的发现,没有维护者的决定**永不**公开立单。
31+
## 编排契约 —— 只写 SWEEP 没有的
32+
33+
六步流程照 `SWEEP.md`「How to run it」执行,⛔ 这里不复述。它没有、而这一轮必须成
34+
立的三条:
35+
36+
1. **Worktree 先行**(PD#11):`git fetch origin main && git worktree add --no-track
37+
../objectstack-<task> -b <branch> origin/main`。全部编辑落在那里;派发任何 agent
38+
之前先读清单现状。
39+
2. **只有编排者碰 `coverage.json``scripts/`** hunter 一个文件都不写,writer 一
40+
个区文件一人 —— 这是并行 agent 之间唯一的串行化依据。
41+
3. **本技能的简报是假设,源码才是真相** —— 每个测试项按 README.md「Item anatomy」
42+
的契约写;缺 fixture 记 `blocked`/`knownGaps`,永不伪造覆盖。
5243

5344
## 规模指引
5445

0 commit comments

Comments
 (0)