Skip to content

Commit 44e6f63

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15341-wide-population-channel
2 parents 2bc5ece + a5cef37 commit 44e6f63

18 files changed

Lines changed: 1333 additions & 167 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
The admin "Used by" panel no longer clears a delete when the caller's own organization is using the item.
6+
7+
`GET /api/v1/meta/:type/:name/references` backs that panel, whose empty case reads "Nothing in the metadata graph points at this item. Safe to delete." — advice given to an operator about to delete something. The door supplied no organization, so the reference sweep read the environment partition only: an organization-scoped `view` (or `dashboard`, `report`, `translation`, `email_template`) pointing straight at the object being deleted was invisible, and the panel issued a false clearance. It now passes the caller's organization, and those references are returned.
8+
9+
The organization is passed RAW, deliberately, and that is the whole of the change — no new parameter, response field or contract surface. `req.params.type` is the reference TARGET, while the sweep spends the organization on the SOURCES it reads per type; `getMetaItems` applies the `allowOrgOverride` read gate to its own request type, so each source is scoped on its own registry flag. A non-overridable source (`object`, `flow`, `app`, …) is still read environment-wide and no pre-#6190 organization-scoped row is resurrected into a delete clearance. An anonymous or organization-less caller reads exactly what it read before, and no status code or response shape moves.

.claude/hooks/guard-main-checkout.selftest.sh

Lines changed: 131 additions & 35 deletions
Large diffs are not rendered by default.

.claude/hooks/guard-main-checkout.sh

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515
# edited from this session are guarded too.
1616
# 3. Resolves the file's nearest EXISTING ancestor dir, so creating a new file in a
1717
# new directory can't fail-open past the guard.
18+
# 4. Reads the path from the key the ROUTED TOOL actually carries — a per-tool table,
19+
# not one hard-coded key — and blocks rather than guessing when a routed tool's
20+
# payload does not carry it. A guard that learns nothing from the payload and falls
21+
# back to judging the session's directory returns a verdict that is constant per
22+
# session and wrong in BOTH directions, decided by where the session happens to be
23+
# rooted rather than by anything about the edit.
1824
#
1925
# Deliberate exception (a human quick-fix that still lands via PR): OS_ALLOW_MAIN_EDITS=1.
2026

@@ -23,12 +29,63 @@ set -uo pipefail
2329
[ "${OS_ALLOW_MAIN_EDITS:-}" = "1" ] && exit 0
2430

2531
input="$(cat 2>/dev/null || true)"
32+
33+
# WHICH tools reach this guard is the matcher's job, in .claude/settings.json; which KEY
34+
# each of them carries its path in is this table's. The two are one pair, and the pairing
35+
# is checked rather than assumed: a tool routed here with no row below would be read for a
36+
# key its payload never carries, so the guard would learn nothing and judge the session
37+
# instead of the file. The self-test's wiring section reads the line below and reds when
38+
# the matcher routes a tool that has no row in it. Add the tool AND its row together.
39+
known_path_keys='Edit=file_path Write=file_path MultiEdit=file_path NotebookEdit=notebook_path'
40+
41+
have_jq=0; command -v jq >/dev/null 2>&1 && have_jq=1
42+
43+
scan() { # scan <key-alternation> -> first "key": "value" a plain text scan finds, no jq.
44+
# JSON escapes the quotes of any key name quoted inside a string value, so a payload that
45+
# merely TALKS about one of these keys cannot outrank the real one.
46+
printf '%s' "$input" | grep -oE "\"($1)\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 |
47+
sed 's/^.*"\([^"]*\)"$/\1/' || true
48+
}
49+
50+
tool=""
51+
[ "$have_jq" = 1 ] && tool="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)"
52+
[ -n "$tool" ] || tool="$(scan 'tool_name')"
53+
54+
key=""
55+
for row in $known_path_keys; do
56+
case "$row" in "$tool="*) key="${row#*=}" ;; esac
57+
done
58+
2659
file=""
27-
if command -v jq >/dev/null 2>&1; then
28-
file="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)"
60+
if [ -n "$key" ]; then
61+
# A tool the table names: read exactly the key that tool is contracted to carry.
62+
[ "$have_jq" = 1 ] && file="$(printf '%s' "$input" | jq -r --arg k "$key" '.tool_input[$k] // empty' 2>/dev/null || true)"
63+
[ -n "$file" ] || file="$(scan "$key")"
64+
else
65+
# A tool the table does not name is not routed here by the matcher. Judge it by whichever
66+
# known key it happens to carry, and fall back to the project dir when it carries none.
67+
[ "$have_jq" = 1 ] && file="$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' 2>/dev/null || true)"
68+
[ -n "$file" ] || file="$(scan 'file_path|notebook_path')"
2969
fi
30-
if [ -z "$file" ]; then
31-
file="$(printf '%s' "$input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/^.*"\([^"]*\)"$/\1/' || true)"
70+
71+
# A ROUTED tool that carries no path under its own key is schema drift, not a missing path:
72+
# the contract this guard was written against has moved. Report it instead of guessing.
73+
if [ -n "$key" ] && [ -z "$file" ]; then
74+
cat >&2 <<EOF
75+
⛔ Blocked: a $tool payload carrying no "$key" — this guard cannot tell which file is being written.
76+
77+
$tool is routed to this guard by .claude/settings.json, and this guard reads that tool's
78+
path from .tool_input.$key. An absent value means the tool's input schema has moved under
79+
the guard. Falling back to the session's directory would make the verdict a constant per
80+
session — right or wrong purely by where the session is rooted, not by the edit — so this
81+
blocks instead.
82+
83+
Fix the row for $tool in this hook's known_path_keys table, then re-run
84+
.claude/hooks/guard-main-checkout.selftest.sh.
85+
86+
Deliberate non-task exception: re-run with OS_ALLOW_MAIN_EDITS=1.
87+
EOF
88+
exit 2
3289
fi
3390

3491
# Judge the checkout at the file's nearest existing ancestor dir (handles new files in

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

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
115115
| `pm:retriage` | 等分诊改判:与现行 `pm:*` 并存、⛔ 不摘原标;带本标签的 `pm:queue` 卡跳过派发 |
116116
| `finding` | 观察类记录,恒 = 待首次定级;定级即离标;不占队列不进收件箱 |
117117
| `target:<major>` | 发版阻塞:每个 backlog 恰好一个生产者 |
118-
| `pm:epic`(父单) | 子树已委托 epic PM;其它 PM 不把其 sub-issue 当候选 |
118+
| `pm:epic`(父单或 sub-issue) | 已由 epic PM 保留;其它 PM 永不取;⛔ 永不与 `pm:queue` 同挂 |
119119
| `pm:seat` | 座位登记贴:协议载体,不是待分诊的工作 |
120120
| `priority:p0` | 插队:可超 `batch`、破轮次立即派发;⛔ 不豁免同文件串行与认领协议 |
121121
| open PR 引用该单 | 已实现,复核中 |
@@ -296,17 +296,17 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
296296
## Epic 子树车道
297297

298298
- 大开发(父单 + sub-issue 树)整体委托一个专职 PM 会话:`/pm-dispatch epic:#<n>`
299-
- 队列 = 子树 open 未认领 sub-issue,每轮重读不缓存。
300299
- 委托信号成对落地:父单打 `pm:epic` + 正文写会话 ID 与声明的文件领地。
301-
- 其它 PM 的候选获取跳过整棵子树。
302-
- 域座位批次选择时读一次 `label:pm:epic` 索引,避开领地相交。
303-
- epic PM 不同时持有 `domain:*` 座位;认领纪律全套照做。
304-
-`packages/spec` 的 sub-issue 照旧转 spec 座位。
305-
- 衍生问题三分法,判据:不修它,epic 验收过不过得去。
306-
- in-scope ⇒ 挂父单 sub-issue 下轮自动入队。
307-
- 触 spec/公共契约 ⇒ 转 spec 座位队列,epic 侧写 `Blocked-by:`
308-
- 顺带发现 ⇒ 独立立单进修复落地仓 backlog,查重先行。
309-
- ⛔ 不借 sub-issue 通道把未分诊的卡塞进池子。
300+
- epic 立卡挂父单、打 `pm:epic`,⛔ 永不 `pm:queue`;`label:pm:epic` 即父子保留全集,域座位不取。
301+
- 队列 = 子树 open 未认领 sub-issue,每轮重读不缓存;其它 PM 候选获取跳过整棵子树。
302+
- 域座位批次选择读一次 `label:pm:epic` 索引,避开领地相交;epic PM 不兼任 `domain:*` 座位。
303+
- 认领纪律全套照做:先写标签、再 `Claim:`、再全线程重读;认领后 `pm:epic` 留在卡上。
304+
- 离开子树(交车道或转 spec 座位)同一笔标签写摘 `pm:epic``pm:queue`
305+
- `pm:epic` + `pm:queue` 同卡 = 半状态(保留兼移交);后者即已移交,取回走全套认领协议含重读。
306+
- 单车道仓(无 `domain:*`)开卡即认领合法;多车道仓域标签前置照旧,子树标记即保留。
307+
- 衍生问题三分,判据:不修它 epic 验收过不过得去;in-scope ⇒ 挂父单 sub-issue 下轮自动入队。
308+
-`packages/spec`/公共契约(sub-issue 或衍生)⇒ 照旧转 spec 座位队列,epic 侧写 `Blocked-by:`
309+
- 顺带发现 ⇒ 独立立单进修复落地仓,查重先行;⛔ 不借 sub-issue 通道塞未分诊卡进池子。
310310
- 每次分流留一行审计评论;父单维护 checklist 汇总评论,决策仍锚在具体 sub-issue。
311311
- 收尾四步、僵尸回收与领地防撞细则见 `references/seat-post-protocol.md`
312312

.claude/skills/spec-property-retirement/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ walk 看不见的属性就是 ratchet 管不到的属性。
114114

115115
墓碑条目的 note 模板(house style 原文,如 `liveness/action.json`):
116116

117-
> `REMOVED <date> (#<issue>) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-<N> conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); <what to do instead>.`
117+
> `REMOVED <date> — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-<N> conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); <what to do instead>.`
118118
119119
### ⚠ 四张 ratchet 的可见性,按路线是**相反**的 —— 拿错对照就会判错
120120

@@ -151,7 +151,7 @@ ratchet(#2978)会先开火,要求你**有意删除**对应的 manifest key;删
151151
五条惯例,树上每个墓碑都遵守 —— 逐点判定归下面那个 pin 测试,不归这段散文:
152152

153153
1. 反引号包着的**全限定**键打头 —— `` `flow.errorHandling.fallbackNodeId` ``,不是裸尾段。
154-
2. `was removed in @objectstack/spec <version> (#issue[, ADR-XXXX Dn])`
154+
2. `was removed in @objectstack/spec <version> (ADR-XXXX[ Dn])`;`#<n>` 归 schema 注释,`check:doc-authoring` 把门
155155
3. 一个破折号从句讲**它为何惰性或错误** —— "it never had an effect"、"no renderer ever read it"。
156156
4. 祈使句修复:改名写 "use `<replacement>`" + "Rename the key; the value (…) is unchanged.";删除写 "Delete
157157
the key." + **真正生效的机制是什么**

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 sites**.
158158
|:--|:---|:---|:---|:---|
159159
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` |
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161-
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5016`, `:6430`, `:6678`, `:7109`, `:7302` |
161+
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5016`, `:6442`, `:6690`, `:7121`, `:7314` |
162162
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
163163
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |

packages/rest/src/execctx-consumer-census.test.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,23 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
309309
// ---------------------------------------------------------------------------
310310

311311
describe('[#13160] §2 the consumer surface, counted from the tree', () => {
312-
it('76 invocation sites, 97 mentions — the thread\'s two control numbers hold', () => {
312+
it('77 invocation sites, 98 mentions — the thread\'s two control numbers hold', () => {
313+
// [#13753, the `/references` half] 76 → 77 sites / 97 → 98 mentions.
314+
// `GET /meta/:type/:name/references` resolved NO identity, so the
315+
// reference sweep behind the admin "Used by" panel read the env
316+
// partition only and rendered "Nothing in the metadata graph points at
317+
// this item. Safe to delete." over an organization it never read. It
318+
// joins as a LOCALLY CAUGHT site in the continuation-line spelling, for
319+
// the same reason as its siblings: this door does not sit behind the
320+
// shared anonymous floor either.
321+
//
322+
// ⚠️ Here the two numbers moved by the SAME amount (+1 and +1), which
323+
// is the third pattern this block has recorded and is not a mistake:
324+
// the door's new comment states the memoised resolution in prose
325+
// WITHOUT naming the symbol, so the call is the only new mention. A
326+
// reader checking the +1/+2 shape of the entries below should read this
327+
// as the mention count tracking mentions, not as a lost site.
328+
//
313329
// [#13753] 75 → 76 sites / 95 → 97 mentions. `GET /meta/diagnostics`
314330
// resolved NO identity, so the Studio governance sweep could not state
315331
// which organization's partition it was reading and reported clean
@@ -361,11 +377,11 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
361377
// `enforceAuth` was measured NOT to be the repair). A mention count
362378
// that tracked the site count exactly would be measuring one thing
363379
// twice.
364-
expect(SITES.length).toBe(76);
365-
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(97);
380+
expect(SITES.length).toBe(77);
381+
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(98);
366382
});
367383

368-
it('the split is 23 locally caught / 53 bare — NOT 16 / 53, which does not add to 76', () => {
384+
it('the split is 24 locally caught / 53 bare — NOT 16 / 53, which does not add to 77', () => {
369385
// 16 sites spell the catch on the invocation line; 4 more spell it on
370386
// the continuation line. A single-line grep sees 16 and the arithmetic
371387
// silently loses four sites.
@@ -375,12 +391,12 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
375391
// be the first of its kind and would break the structural claim below.
376392
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
377393
expect(sameLine.length).toBe(16);
378-
expect(CAUGHT.length).toBe(23);
394+
expect(CAUGHT.length).toBe(24);
379395
expect(BARE.length).toBe(53);
380396
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
381397
});
382398

383-
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 23 caught ones is', () => {
399+
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 24 caught ones is', () => {
384400
// This inverts the reason the thread gave for doing the bare sites
385401
// first ("no local signal that a fault becomes an anonymous subject").
386402
// The bare sites are bare BECAUSE the shared anonymous floor is the

0 commit comments

Comments
 (0)