Skip to content

Commit c4ac01f

Browse files
committed
Merge origin/main into claude/issue-6825-adr-0005-index-migration-amended — union both sides' adr-anchors entries
adr-anchors.json tail-append conflict: this PR added the two ADR-0005 overlay owners (overlay-index.ts, sys-metadata.object.ts); main meanwhile added the ADR-0094 permission-set-projection.ts entry. Resolution keeps all three. Gates re-run on the merged tree: check-adr-anchors OK (46 anchored files), check-adr-links OK (531), check-nul-bytes OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPWqbmEFU8gJepBJTHESXd
2 parents 29efb3c + 63f3b87 commit c4ac01f

54 files changed

Lines changed: 2909 additions & 246 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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
ADR-0094 D5-R: retire the "customize packaged permission sets through an ADR-0005 env
6+
overlay" direction (2026-07-14), and make the ADR text and the
7+
`permission-set-projection.ts` header agree with what is enforced.
8+
9+
`#6483` (PR #6608) rolled `permission` back to `allowOrgOverride: false`, so a metadata
10+
write against a **code-declared (artifact-backed)** permission set is refused with 403
11+
`NOT_OVERRIDABLE` — ADR-0005's security row ("overlays would create silent privilege
12+
drift") is enforced again. The supported channel for those sets is the one ADR-0086
13+
always named: edit the package and re-publish. Environment authoring survives on the
14+
`allowRuntimeCreate` tier, for sets whose definition lives only in `sys_metadata`
15+
(data-door creations, and package sets authored + published through the metadata door);
16+
that tier edits the single stored definition in place and is deliberately **not**
17+
described as a re-route of the retired overlay channel.
18+
19+
No behaviour change: the four production write points keep their current dispositions.
20+
The refusal is left to the producer — `plugin-security` does not re-derive
21+
artifact-backing to pre-empt it — and the two write points that catch a failed metadata
22+
write (the `restore` leg and the boot backfill) keep reporting on the durability channel.
23+
What changes is prose, plus test coverage that can now see the gate: the suite's protocol
24+
stub models ADR-0005's tier gate, so the four cases that pinned the retired direction no
25+
longer pass for want of a stub that could refuse.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
---
4+
5+
fix(plugin-audit): consume the engine's bound `ctx.previous` and record one normalised view on both sides of the diff (#6656)
6+
7+
`plugin-audit` used to fetch its own pre-image. `captureBefore`, registered on
8+
`beforeUpdate` / `beforeDelete`, issued a `ql.findOne` for the target row and
9+
stashed it on `ctx.__previous`, because `HookContext.previous` was "officially
10+
typed but not always populated by the engine itself". That is no longer true on
11+
any path this plugin registers for, so the read is retired and the writer reads
12+
the contract value.
13+
14+
**The read that goes away** (measured with a counting driver on the audited
15+
object, `driver.findOne` per write):
16+
17+
| write | before | after |
18+
|:--|--:|--:|
19+
| single-id `update()` | 2 | 1 |
20+
| single-id `delete()` | 2 | 1 |
21+
| predicate `update()`, 3 matched rows | 3 | 0 |
22+
| predicate `delete()`, 3 matched rows | 3 | 0 |
23+
24+
The predicate column is the larger half and was pure waste. #5574 binds
25+
`input.id` on every per-row *before* context, which defeated the handler's own
26+
`if (!id) return` bulk guard — so it read every matched row, and every result
27+
was discarded, because `__previous` landed on the per-row *before* context while
28+
the per-row *after* contexts (the ones the writer actually runs on) never saw
29+
it. The engine's own matched-row read is untouched and still serves both phases,
30+
so the ledger is unchanged.
31+
32+
**What the ledger records changes, and deliberately.** The two sides of an audit
33+
diff came from two different pipelines: `before` through the engine's read path
34+
(credentials masked, formulas hydrated, file references resolved) and `after`
35+
from the raw write result. That asymmetry — not the redundant read — is why a
36+
write that touched one field recorded phantom "changes" for every secret, file
37+
and formula field on the record. Retiring the read makes both sides
38+
same-source; the writer now also gives them one view, so the surface levels
39+
upward rather than down to raw store contents:
40+
41+
- **Credential fields are masked on both sides.** Single-id delete `old_value`
42+
still reads `••••••••` for a `secret` field — that face is byte-identical.
43+
Change detection still runs on the raw values, so rotating a secret is still
44+
recorded as a change; only the recorded values are masked.
45+
- **A pre-existing leak is closed.** The stored `secret:` ref was already
46+
reaching `sys_audit_log.new_value` on every create and update, and a
47+
`password` field — which ADR-0100 stores in cleartext at rest — was landing
48+
there **in plaintext**, in the audit ledger and in the `sys_activity` summary
49+
rendered in the record feed. Both now record the mask.
50+
- **Virtual (`formula`) fields leave the full snapshots.** `ctx.result` carries
51+
hydrated formulas (#5504) and the raw pre-image structurally cannot, so
52+
create `new_value` would have described a field delete `old_value` could
53+
never carry. Only genuinely virtual fields are dropped: `autonumber` and
54+
`summary` are stored columns present and equal on both sides, and they stay
55+
in the snapshot.
56+
57+
Two consequences worth naming, both narrowing single-id delete to what bulk
58+
delete already did: its `old_value` now records a file field's stored id rather
59+
than the resolved `{id, name, size, url}` object, and drops formula values. An
60+
object whose label field is a formula falls back to the record id in the
61+
`sys_activity` label on delete for the same reason.
62+
63+
No audit coverage is removed: the plugin keeps its `afterInsert` / `afterUpdate`
64+
/ `afterDelete` registrations, which is what holds the engine's pre-image demand
65+
gates open, and every one of them keeps the `excludeObjects` face from #5860.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a hook's `retryPolicy` gets the same defaults whether or not its metadata was parsed (#6832)
6+
7+
`HookSchema` declares `retryPolicy.maxRetries` with `.default(3)` and
8+
`retryPolicy.backoffMs` with `.default(1000)`. The executor that actually
9+
performs the retries read both with `?? 0`. So "how many times does an
10+
under-specified hook retry?" had **two answers**, and which one you got depended
11+
on whether the metadata had been through `HookSchema`:
12+
13+
- parsed — `defineStack({ hooks })`, `PUT /meta`, the Studio form — got **3
14+
retries with a 1000ms backoff**, matching the schema, the generated reference
15+
page and the Studio form;
16+
- unparsed — the public `wrapDeclarativeHook` export, and `bindHooksToEngine`'s
17+
own call, which hands it `Hook` metadata verbatim — got **0 and 0**.
18+
19+
The failure was silent and pointed the wrong way: a retry surface that does not
20+
retry raises no error, logs nothing, and fails no test. It just loses the
21+
recovery the author believed they had configured. This is the divergence #4247
22+
removed from flow `errorHandling` ("one contract, one number"), one surface over
23+
and with the numbers swapped, and the `declared = enforced` case ADR-0049 exists
24+
to close.
25+
26+
`wrapDeclarativeHook` now reads both defaults **out of `HookSchema`** instead of
27+
restating them, so the two paths agree by construction and a future key added to
28+
`hook.retryPolicy` needs no matching edit in the executor.
29+
30+
**The boundary, which is deliberately unchanged — read this if you own hooks.**
31+
`retryPolicy` is `.optional()` with no `.default({})`, so an absent block and an
32+
empty one are different declarations, and they stay different:
33+
34+
- **`retryPolicy` omitted entirely → still zero retries.** No policy was
35+
declared, so none is applied. This is the behaviour every existing hook has
36+
today and it does not change. (Making the omitted case default to 3 would have
37+
"fixed" the divergence by silently giving every hook in every existing app
38+
three retries it never asked for — a larger behaviour change than the defect.)
39+
- **`retryPolicy: {}` or a half-filled block → the declared defaults now apply.**
40+
`retryPolicy: {}` means 3 retries / 1000ms; `retryPolicy: { backoffMs: 500 }`
41+
means 3 retries / 500ms. Previously both of these retried zero times on the
42+
unparsed path. If you wrote an empty or partial `retryPolicy` against a host
43+
that does not parse its hook metadata, that hook now retries as its schema,
44+
docs and Studio form have always said it would.
45+
46+
Any value you wrote explicitly still wins outright, including an explicit
47+
`maxRetries: 0`. The backoff remains linear (`backoffMs * attempt`), which is
48+
what the declared shape describes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
filter: `$icontains` 的实施状态改按实测重写(#6947)
6+
7+
`filter.zod.ts``StringOperatorSchema` 的状态段仍然写着「没有任何后端应答 `$icontains`,五个 driver 一律拒收,直到 #5702 落地」。#5702 已于 2026-08-08 关闭,该段自述的过期条件已经触发,文字与已发布的实现正好相反。
8+
9+
按 driver 逐个实测(同一条 `{ name: { $icontains: 'acme' } }` 打到同时含 `acme corp``ACME CORP` 的样本上,而不是 grep case 分支 —— grep 看不见继承编译器的那一面,会少数一个):**五个 driver 里三个应答**(`driver-sql`;`driver-sqlite-wasm` 通过继承 `SqlDriver`,在另一套 sql.js 引擎上;`driver-turso` 的 local 与 remote 两条传输都应答),**两个响亮拒收**(`driver-memory``driver-mongodb`,均为 `INVALID_FILTER` / 400)。据此改写状态段,并同步 `$icontains``.describe()`(它会渲染进 `content/docs/references/data/filter.mdx`,原文同样停留在「lowerings land with #5702」)。
10+
11+
⛔ 行为零变化:`FILTER_OPERATORS` 未动,`$icontains` 仍然刻意不在词表里 —— 该数组是运行时 allowlist,收进去会让内存 `match()`**不匹配**`true`。词表的真实闸口从此写明是 #6520(JS 求值面),不再是已经落地的 #5702
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): the dialect arm of `classifyIndexFailure` walks `cause` to the same depth the conflict arm does (#6848)
6+
7+
`classifyIndexFailure` had two arms reading two different wrap-depths. #6699
8+
moved the first arm onto `@objectstack/types`' `isUniqueViolationError`, which
9+
follows `error.cause` four levels down because pool and query-builder layers
10+
re-throw with the original attached. The second — the dialect arm — kept reading
11+
`err.message` and stopping there.
12+
13+
So a dialect refusal arriving behind a wrapper (outer prose `Write failed` or
14+
`pool query failed`, the actual `near "WHERE": syntax error` one step down
15+
`cause`) was graded `failed` instead of `unsupported`. The private
16+
`indexFailureText` helper now collects the message channel of the thrown value
17+
**and** of each `cause` below it, bounded at the same `MAX_CAUSE_DEPTH` of 4 the
18+
predicate uses and counted the same way (the thrown value is depth 0). The
19+
dialect vocabulary itself is unchanged — only the text fed to it.
20+
21+
**Why the verdict matters beyond wording.** The two consumers dispose of
22+
`unsupported` and `failed` differently. `view-definition-active-index.ts` treats
23+
them the same (keep the previous index, report at `error`; only the wording
24+
differs). But `ensureOverlayStateIndex` builds the composite **fallback lookup
25+
index** on the `unsupported` branch and on no other — offered precisely because
26+
a dialect that cannot take the partial form should still get the lookup. Under
27+
a `failed` verdict that branch never ran, so `fallback` came back
28+
`not-attempted` rather than `ensured` / `refused` and the degradation target was
29+
silently never attempted.
30+
31+
**Dormant, not a live regression.** No driver shipped today produces the wrapped
32+
shape — each hands knex's error back with the dialect text on the outer message,
33+
which is why every existing case matched on the first read. This closes an
34+
asymmetry before a wrapping raw-SQL driver can land on it; it is also not a
35+
regression from #6699, which only made the contrast visible by deepening the
36+
first arm.
37+
38+
Two details worth knowing if you touch this: the collected levels are joined
39+
with a **newline**, never a space, because two of the dialect alternatives are
40+
multi-word (`where clause`, `near "where"`) and a space would let a phrase be
41+
synthesised across a wrapper boundary that no single driver wrote. And a looping
42+
`cause` chain is **bounded rather than detected** — no visited set — which is
43+
exactly what the predicate this mirrors does.
44+
45+
Arm order is unchanged and still load-bearing: a conflict reported anywhere in
46+
the chain still beats a dialect refusal in the outer prose.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/mcp": patch
3+
---
4+
5+
docs(mcp): `diagnoseEmptyRead` 的 TSDoc 更正一句被证伪的事实 (#6724)
6+
7+
`packages/mcp/src/mcp-server-runtime.ts``diagnoseEmptyRead` 的 TSDoc(#6055
8+
由 PR #6051 落地)为"在空答案之后再跑一次仅取结论的探针,而不是把
9+
`getObject` 换成 `getDiagnosed('object', name)`"这个设计选择给出了两条理由。
10+
其中一条是事实陈述,而它是**错的**:
11+
12+
> `MetadataFacade.getObject`(objectql)返回 `registry.getObject(name)` —— a
13+
> different shape from its own `get()`,因此等价关系在一般情况下不成立。
14+
15+
`SchemaRegistry.getItem``'object'` / `'objects'` 类型直接特判回
16+
`getObject`,所以 facade 的 `get('object', n)` 走的是同一次查找;其后的
17+
`item?.content ?? item` 解包是空操作 —— 合并后的 `ServiceObject` 根本没有
18+
`content` 键。实测:命中时两个成员交回**同一个对象引用**,未命中时双方都是
19+
`undefined`。三个已发布实现由 `packages/objectql/src/
20+
metadata-service-getobject-equivalence.test.ts`(PR #6839)钉住,契约侧的
21+
`IMetadataService.getObject` 自 PR #6723(#6505)起也写明了这条等价关系。
22+
23+
同一句话在 `mcp-server-runtime.metadata-outage.test.ts` 里被复述过一次,一并
24+
更正。
25+
26+
仍然成立的那半条理由被保留:`getObject``IMetadataService` 自己的成员,
27+
#6055 当时它并**没有**被文档化的等价关系,在消费端擅自假定一条正是 Prime
28+
Directive #12 禁止的私有方言 —— 所以解析器当初没有被换掉。
29+
30+
**纯注释,零行为变化。** 这次更正****主张把解析器换成
31+
`getDiagnosed('object', name)`:那是一次独立的判断,由接手的人按其自身利弊
32+
去做,本次改动既不作出也不预设。
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): 把 `$search` 自动默认集三个类型词表的「互不相交」钉成受检事实 (#6934)
6+
7+
`packages/spec/src/data/search-fields.ts``autoDefaultFields` 先测一遍
8+
`SEARCH_AUTO_EXCLUDED_TYPES`**否定**守卫,紧接着才是
9+
`SEARCHABLE_TEXTUAL_TYPES` / `SEARCHABLE_ENUM_TYPES`**肯定**白名单。三个集合
10+
今天两两不相交,所以那行否定守卫改变不了任何一次判定 —— 在完整的 56 个字段类型
11+
域(`FieldType` 枚举 ∪ 三个词表 ∪ 域外探针)× 9 种调用形态 = 504 次解析上逐一比对,
12+
删掉它与保留它的结果**逐字节相同**。本次改动因此**不改变任何行为**
13+
14+
**保留该行,而不是退休它。** 判据是构造出来实测的,不是判断的:给
15+
`SEARCHABLE_TEXTUAL_TYPES` 补一个已在排除集里的类型(`json`),两种形态给出的是
16+
**方向相反、同样沉默**的两种解决 —— 有守卫时该类型被悄悄踢出扫描(fail closed),
17+
没守卫时肯定列表获胜,该类型不仅进入自动默认集,还顺带进入上一层 #4254 的 ingress
18+
allow-list,于是 `$searchFields=<该字段>` 从「拒绝」翻成「接受」,正是 #4483`id`
19+
关掉的那类放宽。排除集里写着 `secret` / `password` / `encrypted` / `vector`
20+
fail open 意味着对被脱敏或重量级列做 `$contains` 子串扫描。所以那行不是安全网
21+
(两个方向都不出声),但它是**更安全的那个平局裁决**
22+
23+
**真正堵住这一类的是钉子。** `search-fields.test.ts` 新增:三个词表两两不相交的
24+
断言,加上两个方向的行为断言(排除集成员必被拒、白名单成员必被纳)。任一方向出现
25+
重叠都会变红,且没有任何单点放宽能让它变成空转 —— 已用临时重叠实测两种形态各自
26+
变红。同时补充:两个肯定列表之间也必须不相交,因为引擎侧
27+
`fieldClausesForTerm` 先分支到 `SEARCHABLE_ENUM_TYPES`,同时命中的类型只会走
28+
option label 映射、永远不会按原文 `$contains` 检索。
29+
30+
守卫处的注释如实写明它是 redundant-by-construction、不承重、以及保留它买到的是
31+
哪个方向,避免下一位作者把它读成「新增可搜索类型时必须同步维护排除集」的规则。

.github/workflows/check-links.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ jobs:
8080
# freeze those 8 on a shrink-only baseline and fail on a NEW one --
8181
# something neither `exclude` nor `.lycheeignore` can express, because
8282
# neither ever tells you an entry stopped being needed.
83+
# `ARCHITECTURE.md` joined this glob in #6867: unlike `docs/adr/**`, it
84+
# carried no pre-existing rot once its 10 dead links + 2 stale path
85+
# references were fixed in the same PR, so -- unlike the ADR directory
86+
# above -- it needs no KNOWN_DEAD_TARGETS-style baseline to land clean.
8387
args: >-
8488
--offline
8589
--root-dir ${{ github.workspace }}/content
@@ -88,5 +92,6 @@ jobs:
8892
'content/**/*.md'
8993
'content/**/*.mdx'
9094
'README.md'
95+
'ARCHITECTURE.md'
9196
# Fail the job if broken links are found
9297
fail: true

.github/workflows/ci.yml

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,9 @@ jobs:
430430
# means a future shard-count change cannot deadlock the repo. `name:` and
431431
# `if: always()` are therefore both load-bearing: this must not become
432432
# `if: !cancelled()` (see dogfood-gate), and it must not be renamed.
433+
# Both halves are now asserted rather than only written down: the `name:`
434+
# literal by `check:required-contexts` (#6865), the `if: always()` and the
435+
# attestation roster by `check:shard-attestation` (#6082).
433436
#
434437
# ── It COUNTS credentials; it does not read one aggregate word (#6082) ──
435438
#
@@ -519,7 +522,8 @@ jobs:
519522
# temporal conformance matrix holds to one standard. Its NAME still says
520523
# "live PG + MySQL" on purpose: the name IS the required check, so renaming
521524
# it would silently drop the gate wherever branch protection lists it — the
522-
# same trap the dogfood shards note below.
525+
# same trap the dogfood shards note below. `check:required-contexts` pins
526+
# this literal, parenthetical and all (#6865).
523527
temporal-conformance:
524528
name: Temporal Conformance (live PG + MySQL)
525529
needs: filter
@@ -960,7 +964,10 @@ jobs:
960964
# EVERY pull request in the repo sat permanently BLOCKED (mergeable, all
961965
# checks green, merge button dead). #3622's own comment called for updating
962966
# branch protection; keeping the contract HERE instead means a future
963-
# shard-count change cannot deadlock the repo a second time.
967+
# shard-count change cannot deadlock the repo a second time — and since
968+
# #6865 the bare name is pinned by `check:required-contexts` rather than
969+
# only described here, so a rename fails in the ESLint job instead of in
970+
# the queue.
964971
#
965972
# Also aggregates dogfood-verify (the CLI pass that used to ride shard 1),
966973
# so the one required context still covers everything it covered before
@@ -1040,8 +1047,9 @@ jobs:
10401047
needs: filter
10411048
# See THE FILTER CONTRACT on the filter job's outputs (#4928). No
10421049
# aggregation gate stands behind this job — its own name IS the required
1043-
# context — so this `if:` is the only thing between a filter flake and a
1044-
# green "Build Core" that built nothing.
1050+
# context, pinned by `check:required-contexts` since #6865 — so this `if:`
1051+
# is the only thing between a filter flake and a green "Build Core" that
1052+
# built nothing.
10451053
if: ${{ !cancelled() && needs.filter.outputs.core != 'false' }}
10461054
runs-on: ubuntu-latest
10471055
timeout-minutes: 30
@@ -1273,7 +1281,8 @@ jobs:
12731281
#
12741282
# The NAME is the required-check contract — the same trap the dogfood shards
12751283
# note above: renaming it silently drops the gate wherever branch protection
1276-
# lists it, with every PR still green.
1284+
# lists it, with every PR still green. Pinned by `check:required-contexts`
1285+
# since #6865.
12771286
console-pin:
12781287
name: Console Pin Gate
12791288
needs: filter

0 commit comments

Comments
 (0)