Skip to content

Commit dc07b50

Browse files
committed
Merge origin/main into claude/issue-5022-drilldown-declare (#5220 ledger surgery)
#5220 (#5107) split the strictness ledger into numbers and prose: every count moved to the generated `…ledger.counts.md` (`gen:strictness-ledger`, os-regen managed), and the hand-written file lost its numeric columns and header figures. This branch had hand-edited exactly those numbers, so both conflicts were mine. Resolution, per the rule the split exists to enforce — never hand-carry a count: * Both conflict hunks taken WHOLE from origin/main (the de-numbered `ui/` triage table header and the `#### ui/ — remaining strip sites` table). * Every number I had touched is DISCARDED and recomputed by the generator: `ui/` 198 → 199, strict 117 → 118, total 482 → 483, `chart.zod.ts` 7 → 8, and the strip-of table's `2 | 8`. All of that is now generator output in counts.md; my diff against main under `docs/audits/` contains no hand-written digit. * The two PROSE edits are re-applied onto main's rows, in the new format: the triage `Class` cell reads `**mixed — 6 authorable, 2 no gate**`, and the remaining-strip row keeps main's `**no gate**` verdict (no split declaration needed — that Class is not `mixed`/`split`), extended with the note that the eighth site arrived with its publish gate already wired. `strictness-ledger.test.ts` merged cleanly: main only APPENDED the #5072 chain- posture cases, and my `chart.zod.ts` 7 → 8 assertion is a unit test of the AST counter, not of the ledger document, so #5107 does not subsume it and it stays where it is. Nothing to re-hang. Verified after the merge: spec 306 files / 7856 tests, lint 57 / 1190, both typecheck clean, `check:generated` 9/9 (including the new counts gate), and `check:strictness-ledger` green on all three of its sections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
2 parents 2f431c9 + ba98e26 commit dc07b50

51 files changed

Lines changed: 4881 additions & 642 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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
---
4+
5+
fix(plugin-audit): stop mirroring chunked-upload progress into the audit ledger (#5202)
6+
7+
`SKIP_OBJECTS` in `audit-writers.ts` excludes operational telemetry / plumbing
8+
from `sys_audit_log` and `sys_activity` — ADR-0057 decision 5, *"stop the
9+
amplifier"*. `sys_upload_session` was the second table missing from group (2)
10+
for the same reason `sys_job_queue` was (#5193): it declares
11+
`lifecycle.class: 'transient'` and its own object comment says what the rows are
12+
worth — *"an upload session is ephemeral state, never business truth"*
13+
(ADR-0057 / #2970 item 4) — but nothing connected that declaration to the
14+
exemption list, which is hand-written.
15+
16+
The audit hooks register for **all** objects and there is no "writes made under
17+
a system context are not audited" exemption, so `StorageMetadataStore`'s own
18+
writes were recorded like user edits. A chunked upload of N parts costs 1 + N
19+
writes — the `createSession()` insert plus one `updateSession()` per chunk — and
20+
then a terminal status update and the row's removal, each producing an
21+
`sys_audit_log` **and** an `sys_activity` row: 2 × (1 + N) rows for one file,
22+
with a `beforeUpdate` snapshot read apiece. Each of those rows was also unusually
23+
fat, because `updateSession()` writes the merged **full** record, so the `parts`
24+
JSON blob that grows with every chunk rode along in each diff's `old_value` /
25+
`new_value`.
26+
27+
Nothing else changes: the exemption is one name in one list, and ordinary
28+
business objects are audited exactly as before. In particular `sys_file` stays
29+
audited — it declares `transient` too, but only to reap tombstones and
30+
unfinished uploads; its rows are mostly permanent business truth and keep their
31+
compliance value.
32+
33+
Operators who tracked upload activity through `sys_activity` should read
34+
`sys_upload_session` (in-progress state) and `sys_file` (the durable record of
35+
what was actually stored) instead.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/metadata": patch
3+
---
4+
5+
fix(metadata): 集群对端的元数据写入现在会失效本节点的 `listCache` / registry (#5109)
6+
7+
多节点部署下,节点 A 改一条 `view` / `permission` / `flow`,节点 B 收到
8+
`metadata.changed` 广播后**只叫醒了 watcher,却没有失效自己的缓存**
9+
`attachClusterPubSub()` 的订阅回调此前只做一件事 —— `notifyWatchersLocal()`,
10+
既不碰 `this.registry` 也不碰 `this.listCache`。后果是 B 上任何走 `list(type)`
11+
的读在 `LIST_CACHE_TTL_MS`(30 秒)窗口内继续返回改动前的清单;更糟的是,被叫醒的
12+
watcher(ObjectQL SchemaRegistry 桥、Studio HMR SSE)如果回头调 `list()` 重新拉取,
13+
拉到的还是旧的 —— 一份「失效通知」附带着失效数据。单机部署完全无感,只有多节点才暴露。
14+
15+
这与该通道自己声明的用途相反(`ClusterMetadataChangedPayload`:"consumed by peers
16+
to **invalidate their local caches**",另见 `content/docs/kernel/cluster.mdx` §6.2
17+
`metadata-lifecycle.mdx`);现在实现与声明一致。
18+
19+
修法沿用同文件里 `applyRepoEvent()` 自 ADR-0008 PR-6 起就用对的那条路径,并把两条
20+
「外部写入」缝(仓库 watch 循环、集群对端回放)收敛到同一个私有方法
21+
`invalidateForForeignWrite(type, name)`:
22+
23+
- **删除而不预填。** 即便事件带着 body,也只删除 registry 条目而不写入 ——
24+
那份 body 是别人那次写入的快照,可能已被后续写入取代,预填会与真实 head 竞态,
25+
并要求我们去规范化一份自己没有加载过的定义。删除后 `get()` 自然穿透到 loader /
26+
repository,也就是真相所在。
27+
- **同步失效,先失效再通知。** 失效发生在收到消息的当拍(不在 `setImmediate` 内),
28+
通知仍然延迟派发。`setImmediate` 的存在理由是不让**消费方的 watcher 回调**背压
29+
pubsub 派发循环;而失效只是两次 `Map.delete`,不执行任何消费方代码,没有需要延迟的
30+
东西——把它一起延迟只会留下「已收到广播、尚未失效」的读窗口,请求处理器里任何一个
31+
`await` 都足以撞进去。先失效后通知也与本文件其他写入路径
32+
(`register` / `unregister` / `applyRepoEvent`)一致,于是回头 `list()` 的 watcher
33+
拿到的是写后清单。
34+
- **无名事件只失效清单缓存。** `MetadataWatchEvent.name` 在 spec 里是可选的,无名事件
35+
无法定位 registry 条目;此时不会把整个 type 的 registry 一并清掉 —— 那会驱逐
36+
`registerInMemory()` 注册的、任何 loader 都无法恢复的代码态构件(如 `origin:'code'`
37+
的 datasource)。
38+
39+
回环抑制(`originNode`)仍然先于失效判断,本节点自己的广播不会让自己白白重建缓存。
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/service-queue": patch
4+
---
5+
6+
fix(objectql,service-queue): a `lifecycle` settings override can no longer undercut a consumer's retention floor (#5195)
7+
8+
ADR-0057 P4 lets an operator override any object's retention window per
9+
environment and per tenant through the `lifecycle` settings namespace. Until now
10+
the only validation on that override was **does it parse** — and a retention
11+
window is not only the operator's business: other code can depend on the rows
12+
still being there.
13+
14+
`sys_job_queue` is the worked example. `DbQueueAdapter` deduplicates publishes by
15+
comparing a terminal row's `created_at` against its idempotency window, so the
16+
dedup check only means anything while that row still exists; #5179 made the
17+
ordering an invariant by refusing, at construction, an idempotency window longer
18+
than the object's **declared** retention. A settings override the constructor
19+
cannot see walks straight around it:
20+
21+
```jsonc
22+
// lifecycle → retention_overrides
23+
{ "sys_job_queue": { "maxAge": "1h" } }
24+
```
25+
26+
completed rows are reaped an hour after they are written, publish keeps
27+
deduplicating against 24h, and duplicate deliveries resume **with nothing in any
28+
log**.
29+
30+
**New: retention floors.** A consumer may now declare, at runtime, the shortest
31+
window its own contract survives:
32+
33+
```ts
34+
lifecycle.registerRetentionFloor('sys_job_queue', {
35+
policy: 'retention', // or 'ttl'
36+
minWindowMs: 24 * 60 * 60 * 1000,
37+
declaredBy: 'com.objectstack.service.queue',
38+
consequence: '…what silently breaks below it',
39+
remedy: '…the settings change that makes an override legal',
40+
});
41+
```
42+
43+
- An override below the floor — **global or tenant-scoped** — is **rejected**,
44+
and the declared window keeps running. Not clamped to the floor: clamping
45+
would enforce a third number written in neither the declaration nor the
46+
settings, and that number would move whenever an unrelated package changed
47+
its floor. Rejection has exactly one fallback, the declaration, which is
48+
already how an unparseable override resolves.
49+
- The rejection is `error`-level and carries both the consequence and the fix,
50+
because what it prevents leaves the system looking entirely healthy. It is
51+
also on the sweep report as `LifecycleSweepReport.floorViolations` — machine-
52+
readable, every sweep.
53+
- A **declared** window below a registered floor is reported the same way and
54+
still enforced: refusing to reap would trade a broken consumer contract for
55+
the unbounded table #5179 just closed.
56+
- Objects with no registered floor are completely unaffected — P4 overrides
57+
behave exactly as before.
58+
59+
Floors are runtime wiring, not spec surface (the same call ADR-0057's reap-guard
60+
amendment makes), plus a reason of their own: the queue's floor **is**
61+
`DbQueueAdapterOptions.idempotencyWindowMs`, a per-kernel construction option, so
62+
a static key on the object's `lifecycle` block could only ever be a second copy
63+
of it that drifts. No `packages/spec` change.
64+
65+
`QueueServicePlugin` registers `sys_job_queue`'s floor on `kernel:ready`,
66+
carrying the window the adapter was actually constructed with — so a non-default
67+
`db.idempotencyWindowMs` is covered too. The ordering is now enforced from both
68+
sides: the constructor rejects a too-long `idempotencyWindowMs`, the floor
69+
rejects a too-short `maxAge`.
70+
71+
New exports from `@objectstack/objectql`: `LifecycleRetentionFloor`,
72+
`LifecycleFloorViolation`, plus `LifecycleService.registerRetentionFloor()`.
73+
`LifecycleLoggerLike` gained an optional `error()` (absent ⇒ falls back to
74+
`warn`), and `LifecycleSweepReport` gained `floorViolations`.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
tooling: strictness 台账「数字/散文分家」—— 计数转生成物走 os-regen,判定与依据保持手写 (#5107, #5072)
6+
7+
`docs/audits/2026-07-unknown-key-strictness-ledger.md` 是战役期间全仓最热的合并冲突点,而冲突全部落在它的**数字**上:两个批次各按自己那份正确的增量去减表头,git 把互不重叠的****干净合并,而与谁都不冲突的**小计行**「干净合并、两边都错」—— 单日 7 例,`ui/` 小计被三个批次分别写成 119 / 110 / 100,而合并后的正确值 91 三边都没写过。**散文当天只冲突过一次,而且那次是有意义的。**
8+
9+
所以数字走了。新增生成物 `docs/audits/2026-07-unknown-key-strictness-ledger.counts.md`(`gen:strictness-ledger`),承载每文件站点数/strip 数、各段表头、按类小计、posture 分布与未细分目录的总数;`.gitattributes` 把它加进 `merge=os-regen`(#4675),合并时不做文本合并、整体重生成,`pre-commit` 在重生成之前拒绝提交。台账本体只留承重的部分:`Class` 判定、依据、findings log、豁免记录 —— 这些是判断,不是算术,重生成会**删掉别人的证据**,所以 `docs/audits/**` 仍在 `NOT_DRIVER_MANAGED` 里。
10+
11+
`check:strictness-ledger` 的职责随之反转,双向闸语义完整保留:
12+
13+
- **生成物新鲜**:整份重新渲染后逐字节比对,失败信息把「某个数字动了 = 有 schema 在没人重新审视的 `Class` 判定下被增删/改姿态」这句话说出来 —— 这正是旧的手写计数唯一值得留下的那一半;
14+
- **散文自洽**:每条手写行必须指向一个仍然存在、仍然有站点的文件(旧的计数检查顺带买到的性质),有站点却没有行仍然红,strip 行归零仍然红(反向钉)。
15+
16+
两条红路径都先证了红再信绿:改生成物里一个数字 → EXIT=1;删一条手写行 → EXIT=1(并且被删的行不会静默变成 0,它会以 `⚠️ unclassified` 出现在生成物里)。
17+
18+
小计是「对判断做算术」,所以 remaining-strip 那张表的 `Class` 单元格现在有语法:`<verdict> [(p)] [· <n> <verdict>, …]`。已解决的 `mixed`/`split` **必须**声明自己的拆分,闸门拒绝猜 —— 一个宽容的解析器会把这些数字原封不动地送回它们刚被搬走的地方,而且是在一个绿色的文件里。迁移后所有已发布的数字逐一复现(235 strip / 36 open files / authorable 29 / unresolved 33 / no door 38 / no gate 31)。
19+
20+
**同一把尺的搭车修复(#5072)**:`postureOf()` 对战役自己的 helper 短路 —— `strictObject(` 直接返回 `strict`,不看链上挂了什么,于是 `strictObject(…).passthrough()`(运行期**开放**的形状)在台账里被记成 **strict**。全仓恰好 2 处,都在 `ui/view.zod.ts`(`GanttConfigSchema` / `TreeConfigSchema`),两处的 `.passthrough()` 都是刻意的。现在 idiom 只决定**起始**姿态,链一律走完,最后一个显式调用赢。`ui/` 的 strict 读数 119 → 117、passthrough 3 → 5;**strip 计数不变**,所以 remaining-strip 那张双向表的数字一个都没动。零 `*.zod.ts` 语义改动。
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
"@objectstack/plugin-email": minor
4+
---
5+
6+
feat(plugin-email,platform-objects): `sys_email` carries headers and small attachments, so those messages become durably deliverable (#5177)
7+
8+
Durable email delivery works from the **row**, not from the in-memory message:
9+
`send()` publishes an `{ rowId }` job (#5160), the boot sweep re-reads rows
10+
(#5161), and both end at `rowToNormalized`. So anything a `sys_email` row could
11+
not carry, a row-based delivery would have dropped — and custom headers and
12+
attachments were exactly that. The honest workaround was to refuse: a message
13+
with either was pushed back onto inline delivery so that it would at least go
14+
out whole, which closed the durable path to precisely the mail most worth
15+
making durable (a signed receipt, a `List-Unsubscribe` header, an invoice PDF).
16+
17+
`sys_email` now has two columns, and those messages are queueable.
18+
19+
**`headers_json`** — the custom headers, as a JSON object. Written in both
20+
delivery modes (it is audit evidence as much as delivery input) and rebuilt on
21+
read. Headers are no longer a reason to fall back to inline delivery.
22+
23+
**`attachments_json`** — attachments as a JSON array of
24+
`{ filename, contentType?, size, hash, cid?, contentForm, inline?, storageKey? }`,
25+
content base64 in `inline`. Written when the **combined raw size of one
26+
message's attachments is within `SYS_EMAIL_ATTACHMENT_LIMIT_BYTES` (256 KiB,
27+
exported from `@objectstack/plugin-email`)** — worst case ~350 KB of base64, so
28+
a row stays bounded. Both arms of the declared `content: string | Buffer`
29+
contract round-trip as the arm they were sent as: restoring a text attachment
30+
as a Buffer would silently drop `charset=utf-8` from its MIME part and let the
31+
recipient's client mis-decode a UTF-8 file, so `contentForm` records which one
32+
it was. `cid` travels too — an inline `<img src="cid:…">` is unusable without
33+
it.
34+
35+
**Over the limit, nothing changes.** The message is delivered inline exactly as
36+
before, whole, and the row stores no attachment content; the reason is stated
37+
at `info` (a bound, not a degradation — the worst outcome is today's
38+
behaviour). Out-of-row storage for large attachments is #5172; `storageKey` is
39+
declared now so that lands as a new *producer* rather than a data migration.
40+
41+
Rows written before these columns exist read exactly as they did. A column that
42+
is present but does not describe what it claims — malformed JSON, a size or
43+
hash that disagrees with the content, a missing `contentForm` — is **rejected**,
44+
and the row lands at `failed` carrying the reason, rather than being delivered
45+
with a part quietly missing.
46+
47+
The `sys_email` schema change is additive (two optional textarea columns); no
48+
migration is required and default inline delivery is unchanged.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): `updateMany` classifies an id-less row as a caller error, matching `batchData`'s update branch (#5100)
6+
7+
`runUpdateManyLoop` lacked the `!record.id` guard #4793 gave `runBatchDataLoop`'s
8+
update branch, so the two by-id update faces classified the same malformed row
9+
differently: `VALIDATION_FAILED`/400 on batch, but on `updateMany` the row fell
10+
through to the #5088 existence probe as `{ id: undefined }` and came back
11+
`RECORD_NOT_FOUND`/404 with `undefined` interpolated into the message — a
12+
request-shape error reported as a data-state one, with the row's fate left to
13+
each driver's undefined-where-key handling.
14+
15+
Not reachable over REST (`UpdateManyRecordSchema` requires `id`, #3939) — the
16+
change is observable only to in-process callers of the protocol method, whose
17+
id-less rows now answer `VALIDATION_FAILED`/400 (`Record id is required for
18+
update`) before any engine round-trip, identically on both faces (#4620: one
19+
classification per file, enforced by a cross-face parity test). `record.data`
20+
handling is aligned to the batch branch's `record.data || {}` in the same
21+
change.

.claude/skills/pm-dispatch/SKILL.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -915,8 +915,8 @@ Verdict per issue:
915915

916916
**A. 碰生成物的 PR,入队前必须先同步 + 整体重生成。** 第 3 步只保证**同一批内**
917917
file-disjoint;它管不到**先后两单都碰 `packages/spec` 生成物**的情形 —— 而协议变更
918-
几乎必然如此。`.gitattributes` 把这七条路径路由到 `merge=os-regen`(⛔ 别只记住前
919-
五条 —— 后两条是文档产物,同样会被静默吞):
918+
几乎必然如此。`.gitattributes` 把这八条路径路由到 `merge=os-regen`(⛔ 别只记住前
919+
五条 —— 后三条是文档产物,同样会被静默吞):
920920

921921
```
922922
packages/spec/spec-changes.json
@@ -925,9 +925,14 @@ packages/spec/json-schema.manifest.json
925925
packages/spec/api-surface.json
926926
packages/spec/api-surface-signatures.json
927927
docs/protocol-upgrade-guide.md
928+
docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
928929
content/docs/references/**
929930
```
930931

932+
最后那条是 #5107 加的:strictness 台账的**数字**转成了生成物(`gen:strictness-ledger`),
933+
散文仍手写在同名的 `.md` 里。派 #4001 后续批次时要分清 —— **台账正文照常文本合并
934+
(它一直合得很干净),只有 `.counts.md` 走驱动**
935+
931936
权威清单是 `.gitattributes` 本身(`grep os-regen .gitattributes`),不是这份拷贝 ——
932937
它增删过,以文件为准。
933938

.gitattributes

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,19 @@
2727
# dual-source-exports.baseline.json (shrink-only ratchets — recomputing can
2828
# WIDEN them), variant-docs.json and the migrations/conversions registries
2929
# (hand-written). Those conflicts are for a human. See NOT_DRIVER_MANAGED.
30+
#
31+
# The strictness ledger's COUNTS file joined at #5107 — the ledger's numbers were
32+
# the repo's hottest conflict surface and merged in the one way that hides: two
33+
# batches each decrement a header by their own correct delta, the rows merge
34+
# cleanly because they do not overlap, and the subtotal merges clean and WRONG
35+
# (seven cases in one day). Note it is the counts file, not the ledger — the
36+
# ledger's prose is hand-written and must never be resolved by regenerating.
3037

31-
packages/spec/spec-changes.json merge=os-regen
32-
packages/spec/authorable-surface.json merge=os-regen
33-
packages/spec/json-schema.manifest.json merge=os-regen
34-
packages/spec/api-surface.json merge=os-regen
35-
packages/spec/api-surface-signatures.json merge=os-regen
36-
docs/protocol-upgrade-guide.md merge=os-regen
37-
content/docs/references/** merge=os-regen
38+
packages/spec/spec-changes.json merge=os-regen
39+
packages/spec/authorable-surface.json merge=os-regen
40+
packages/spec/json-schema.manifest.json merge=os-regen
41+
packages/spec/api-surface.json merge=os-regen
42+
packages/spec/api-surface-signatures.json merge=os-regen
43+
docs/protocol-upgrade-guide.md merge=os-regen
44+
docs/audits/2026-07-unknown-key-strictness-ledger.counts.md merge=os-regen
45+
content/docs/references/** merge=os-regen

content/docs/kernel/cluster.mdx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -346,9 +346,20 @@ The transport is handed to the manager by `MetadataClusterBridgePlugin`
346346
`kernel:ready`. `Runtime` registers that bridge automatically alongside the
347347
cluster service.
348348

349-
Peers suppress their own messages by `originNode` and replay the watch
350-
event locally — there is currently **no** `version` / `name` / `tenantId` /
351-
`operation` field and **no** version comparison.
349+
On receipt a peer suppresses its own messages by `originNode`, then — **first,
350+
synchronously** — invalidates its local caches for that type: it drops the
351+
`registry` entry named by the nested watch event and the `list(type)` cache
352+
(`invalidateForForeignWrite`). Only then does it replay the watch event into
353+
its local watch hub, deferred by one tick so a slow consumer callback cannot
354+
back-pressure the pubsub dispatch loop. That order is what lets a woken
355+
consumer answer by re-reading through `list()` and see the write rather than
356+
the pre-write set (#5109). The registry entry is **deleted, never pre-filled**
357+
from the payload — the peer re-reads the shared store, which is the source of
358+
truth.
359+
360+
At the payload's top level there is still **no** `version` / `name` /
361+
`tenantId` / `operation` field and **no** version comparison; the item's name
362+
is carried only inside the replayed `event`.
352363

353364
**Target spec (planned).** The richer, version-stamped payload below is
354365
defined as `MetadataChangedEventPayloadSchema` in `kernel/cluster.zod.ts`

0 commit comments

Comments
 (0)