Skip to content

Commit 84c55b4

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-18058-package-install-door-enforce
2 parents 1f9ae64 + a84da60 commit 84c55b4

30 files changed

Lines changed: 2977 additions & 232 deletions
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
the resume door's `repairable` is answered by the engine on the exits that stamp no status — `IAutomationService` declares the read-only `inspectConsumedSuspension` (#17541)
7+
8+
Clause-②: yes (widening)
9+
10+
The resume route's `400 FLOW_FAILED` details computed `repairable` as the single
11+
expression `status === 'stranded'`. That word is stamped on exactly one exit —
12+
the run that consumed its OWN pause and then threw downstream. The subflow
13+
DELEGATION exit stamps nothing on purpose: a caller resumes the PARENT, the
14+
signal is forwarded down, the child strands, and the parent frame answers
15+
`{ success: false, error, durationMs }`, because nothing re-arms an ancestor by
16+
resuming it and stamping `'stranded'` there would send an operator to retry a
17+
recovery that cannot succeed.
18+
19+
Since the nested-chain restore landed, that parent's consumed pause IS
20+
journalled and one `restoreConsumedSuspension(parentRunId)` re-arms the whole
21+
chain leaf-first. So the wire answered `repairable: false` about a run the
22+
operator verb WILL repair, and a client written exactly as the reference page
23+
instructs closed it as terminal. Measured through the HTTP route, before and
24+
after, on the same parked delegation:
25+
26+
```json
27+
before 400 { "error": { "code": "FLOW_FAILED",
28+
"details": { "runId": "run_…", "repairable": false } } }
29+
after 400 { "error": { "code": "FLOW_FAILED",
30+
"details": { "runId": "run_…", "repairable": true } } }
31+
```
32+
33+
…while at that same instant the engine answered
34+
`inspectConsumedSuspension(runId) → { repairable: true, witness: 'journal' }`
35+
and `restoreConsumedSuspension(runId) → { restored: true, chain: [child, parent] }`.
36+
37+
**`@objectstack/spec` — additive, `minor`.** `IAutomationService` declares the
38+
optional read-only member `inspectConsumedSuspension(runId)`, which
39+
`AutomationEngine` already implements publicly: would the restore verb have a
40+
consumed suspension to put back for this run? It re-arms nothing and reads the
41+
same two witnesses that verb reads, so what it calls repairable IS what that
42+
verb restores. The declared result is deliberately narrower than the
43+
implementation's, the way `restoreConsumedSuspension`'s already is — `reason` is
44+
typed as the string the implementation answers, not as an enumeration this
45+
contract would have to keep in step, and the engine's wider type satisfies it
46+
under `implements`. `ResumeFailureDetailsSchema.repairable`'s `.describe()` is
47+
rewritten to the truth and the generated reference page regenerated with it. No
48+
key is added, renamed or retired on any wire schema.
49+
50+
**`@objectstack/runtime` — the door.** On a `400 FLOW_FAILED` whose result
51+
carries a `status`, that stamp still decides, and the engine is not consulted at
52+
all. On a result that carries none, the door asks the declared member and relays
53+
its `repairable`. Both ways of not getting an answer are FAIL-CLOSED: a service
54+
that declares no inspection member answers `false` exactly as it did before, and
55+
an inspection that REJECTS (a store it could not read) answers `false` and says
56+
so once at `warn` — an unreadable store is UNKNOWN, not "nothing to restore",
57+
and it is never allowed to replace the `400` the caller asked for with a `500`.
58+
59+
⛔ The fence is untouched: a cascade-failed ancestor is still never STAMPED
60+
`'stranded'`. Its repairability is carried by the journal and REPORTED by the
61+
inspection, which is exactly why the door asks instead of reading a word. ⛔ And
62+
no new `AutomationResult.status` member is minted for this exit — there is
63+
nothing new for a client to learn, and `details.repairable` is the member a
64+
client was already told to branch on.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): `POST /api/v1/batch` answers the same thing for a wired-and-failing engine on every wiring — 503, the answer this slot's two other consumers already give (#18559)
6+
7+
`objectQLProvider` has three consumers in `packages/rest/src/rest-server.ts`. Two reach the
8+
seam through `wiredEngineOrLoud`, which keeps "no engine is wired" and "the engine WAS wired
9+
and could not be resolved" as two facts. The cross-object batch door read the field directly,
10+
so a rejection escaped the read, missed the adjacent `501 NOT_IMPLEMENTED` arm (it tests
11+
`!ql || typeof ql.transaction !== 'function'`, which a rejection never reaches) and landed in
12+
the handler's generic outer `catch`.
13+
14+
⛔ **Not a re-collapse and not a regression.** The two facts always differed on the wire, so
15+
the decidable test #14251 tightened was already satisfied at this consumer. What was wrong is
16+
that they differed *through a catch-all that knows nothing about this seam*.
17+
18+
**What moves, measured on a real `RestServer` over a real `ObjectKernel`, driven at the door:**
19+
20+
| wiring, engine wired and FAILING | before | after |
21+
|:--|:--|:--|
22+
| single-kernel (the composition the open core boots) | 503 `SERVICE_UNAVAILABLE` | 503 — unchanged |
23+
| multi-kernel (a `kernelManager` is wired) | **500 `INTERNAL_ERROR`** | **503 `SERVICE_UNAVAILABLE`** |
24+
25+
⭐ The single-kernel row is why this is a de-divergence rather than a new wire ruling: there
26+
`computeExecCtx` resolves the engine through its own `wiredEngineOrLoud` branch and raises
27+
before the batch handler's engine line runs, so this door already answered 503. The 500 was
28+
reachable only where that gate's kernel branch absorbs by design and hands the engine question
29+
down. An operator got one of two answers for one fact depending on which composition was
30+
running — and 500 and 503 are not synonyms to a client: one says "I am broken", the other says
31+
"I am temporarily unavailable, retry".
32+
33+
**Unchanged, and pinned:** both ABSENCE shapes still answer `501 NOT_IMPLEMENTED` on both
34+
wirings — no provider wired at all, and a provider that RESOLVES `undefined`, which is the
35+
seam contract declaring absence rather than failing. The fault MESSAGE is still withheld
36+
(`Internal server error`); only the status and the machine code move. `SERVICE_UNAVAILABLE` is
37+
an existing `StandardErrorCode` already emitted by the sibling `/meta/object/:name/state/:field`
38+
door for this same fact — no new code, no new payload key, no new export.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
'@objectstack/client': patch
3+
---
4+
5+
`organizations.getActiveMember`'s own prose says what an anonymous caller gets TODAY: `401 UNAUTHENTICATED` on request ONE — not `200 null` and then a `401 UNAUTHORIZED` from `list-members`
6+
7+
objectstack#17881 (`374d9d3afa`) landed `plugin-auth`'s
8+
`refuseAnonymousSession`, which converts better-auth's `200` + the literal JSON
9+
`null` on `GET /api/v1/auth/get-session` into the declared ADR-0112 refusal
10+
envelope — HTTP `401`, `code: UNAUTHENTICATED` — before it leaves the process.
11+
`@objectstack/client` reaches the server over the wire, so that is what it
12+
sees. Three present-tense statements in and around `getActiveMember` still
13+
described the retired shape, and they were wrong on two axes at once: the CODE
14+
(`UNAUTHORIZED` vs `UNAUTHENTICATED`) and the REQUEST the refusal arrives on
15+
(the second one, `list-members`, vs the first, `/get-session` itself).
16+
17+
**FROM → TO for a caller.** `getActiveMember` makes two requests for a
18+
signed-in caller. For an anonymous one it now makes ONE, and rejects:
19+
20+
| you wrote | write instead |
21+
|:--|:--|
22+
| `try { await c.organizations.getActiveMember(id) } catch (e) { if (e.code === 'UNAUTHORIZED') … }` | `… catch (e) { if (e.code === 'UNAUTHENTICATED') … }` |
23+
24+
The behaviour is objectstack#17881's and shipped then; what moves here is only
25+
the SDK's description of it. A reader coding against the old prose caught the
26+
wrong code, and expected the refusal on a request that is never put on the
27+
wire.
28+
29+
**What changed**
30+
31+
- Step 1 of the two-request list no longer says `/get-session` serves "the
32+
literal `null` for an anonymous one". The signed-in arm keeps its
33+
`(measured)` tag, which is still the 2026-09-09 drive's; the anonymous
34+
answer is stated separately and anchored to the producer, including that
35+
step 2 never reaches the wire.
36+
- The anonymous bullet of that drive's delta list no longer says an anonymous
37+
caller "still gets `401 UNAUTHORIZED`, thrown from the `list-members`
38+
request". It is RE-ANCHORED rather than restamped — the drive's own row is
39+
kept in the past tense and today's answer is stated from the producer, the
40+
same disposition objectstack#18642 used on this family's sibling statements.
41+
- The inline comment on the `userId` read no longer says `Anonymous → null`.
42+
It says an anonymous caller never reaches that line, and says why the
43+
`| null` annotation and the `?? ''` fallback stay as the defensive branch
44+
they always were.
45+
46+
⛔ No behaviour changes. `packages/client/src/index.ts` changes COMMENTS ONLY —
47+
verified mechanically: of every line the diff touches in that file, zero are
48+
outside a comment.
49+
50+
**This is shipped, which is why it carries a changeset rather than
51+
`skip-changeset`.** `@objectstack/client`'s published `files[]` is
52+
`["dist","README.md","CHANGELOG.md"]` and `getActiveMember` is a member of the
53+
exported `ObjectStackClient`, so its TSDoc is emitted into the shipped
54+
artifacts. Measured on the built `dist` at `13e09a5e3c`: the corrected sentence
55+
is present exactly once in `dist/index.d.ts`, `dist/index.d.mts`,
56+
`dist/index.js` and `dist/index.mjs`; the retired sentence is absent from all
57+
four; and `getActiveMember` was carried as the lit control, found in every one
58+
of them. ⚠️ This package emits no `.d.cts` and no `.cjs` — its CJS pair is
59+
`index.js` + `index.d.ts` and its ESM pair is `index.mjs` + `index.d.mts`, so
60+
a `*.d.cts` check here would have measured an absent file.
61+
62+
Clause-②: no — no schema key moves, no closed set gains or loses a member, no
63+
published export changes and no registry row is touched. `UNAUTHENTICATED` is
64+
an existing `StandardErrorCode` that objectstack#17881 already derives through
65+
`standardErrorCodeForHttpStatus(401)`; nothing is minted here. The direction is
66+
a pull-back: the runtime has answered `401` since objectstack#17881 and the
67+
SDK's self-description was lagging.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
`os lint` runs the per-package author-time rule pass the other two doors already ran
6+
7+
`os build` has run the author-time rule table a second time, once per
8+
`packages[]` entry with that package's body as the stack and the artifact's own
9+
`packages[]` as resolution context, since #16611; `os validate` joined it in
10+
#18677. `os lint` ran the union fold and stopped, so every finding that pass
11+
produces — "exactly the set the union could not see", in the build command's own
12+
words — was reported by the command that ships and invisible on the fastest of
13+
the three doors. All three now call the one shared pass.
14+
15+
Measured on a two-package project whose union run is clean and whose per-package
16+
run is not (one package owns an object, a sibling package owns the view that
17+
displays its field):
18+
19+
| | before | after |
20+
|---|---|---|
21+
| `os build --json` | warnings 1 | warnings 1 |
22+
| `os lint --json` | total 0, exit 0 | total 1, exit 0 |
23+
| `os lint --json --strict` | exit 0 | exit 1 |
24+
25+
**BREAKING** — `os lint --strict` can now fail a project it passed before. A
26+
per-package finding is a finding this door could not see, `--strict` is
27+
documented as "treat warnings as errors", and the verdict moves with it. The
28+
default face is unchanged in the measurement above, and the severity mapping is
29+
`os lint`'s own: an `error` fails the run, a `warning` fails it only under
30+
`--strict`, an `info` stays a suggestion. Nothing is refused here that `os build`
31+
does not already refuse, so the pre-flight is narrowed to the bar the command
32+
that ships already holds and never past it. A run that must keep its old verdict
33+
drops `--strict`; a project that wants to keep it fixes what the pass reports,
34+
which is the same thing `os build` has been reporting all along.
35+
36+
Clause-②: yes (narrowing)
37+
38+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes changes: no spec key, export, config field or payload key is removed, renamed or added. What moved is which stacks one CLI command's existing rule table is run over, so `objectstack migrate meta` has nothing to rewrite and the ledger has nothing to record. -->

‎.claude/skills/pm-dispatch/references/dispatch-runbook.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,6 @@
198198
- 两种改写都在一轮内闭合:允许做 ⇒ 只做最小编辑,并在报告里单列该判断与回退方式。
199199
- 不许做 ⇒ ⛔ 不要动该文件,让门禁红着并在报告里点名该站点。
200200
- 派发那刻现取、随派发词下发的两份读数,判据同一:PM 已有的读数下发一次。
201-
- ⛔ 不让 N 个 dev 各重跑同样贵的 GraphQL 读。
202201
- ① 门禁清单单班之内即过期;全 farm 归 CI 跑一次,⛔ 不让 dev 枚举全 farm。
203202
- 点名单当天现取仍会漏,dev 对实际改动路径重取补跑。
204203
- ② 去重或看板读数取一次(查询式加时间戳)整批共用。
@@ -234,6 +233,7 @@
234233
- 结论三选一:已改、本就合规(证据)、明确不在范围(理由)。
235234
- ⛔ 静默略过:评审把没提到的面读作漏掉的面。
236235
- 测量先行卡(修复由测量结果有条件授权)⇒ 派发令写明 ⛔ 测量存在之前不写修复。
236+
- 探针做在 dev 要留的分支上,⛔ 不用一次性分支:建得出删不掉,遗留分支被预检读成认领。
237237

238238
## security-object 无判决枚举(座位贴常设 ⑦)的方法
239239

‎.claude/skills/pm-dispatch/references/platform-readings.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -371,10 +371,11 @@
371371
- 有的现场 regen 一件没跑,推送前先跑生成物门禁别赌。
372372
- ③:死在源码编辑中途 ⇒ 先读 diff 判完整性:docblock 写全动机与判据的可代跑终验后提交。
373373
- 写一半意图不明的 ⛔ 不代提交,记交接;dev 临时目录(`.os-scratch/` 一类)清掉,⛔ 不进 PR。
374-
- 零提交的探针分支不是在飞工作:容器发不出分支删除 refspec。
375-
- `git push origin --delete <b>` 回 send-pack: unexpected disconnect,三次退避全败,同会话普通 push 正常。
376-
- ⇒ 测量型派发留下的探针分支永久堆在 origin 上。
374+
- 零提交的探针分支不是在飞工作:容器建得出远端分支却删不掉,两会话两分支实测。
375+
- 两道皆 403:`git push --delete` 回 RPC failed,`DELETE /git/refs/heads/…` 回 not permitted,⛔ 不重试。
376+
- ⇒ 探针分支永久堆在 origin 上,只有带删权的手(维护者)清得掉,本闭环内无人有该权。
377377
- 判据两读:`git rev-list --count origin/main..origin/<b>` 为 0,且分支名下无 open PR。
378+
- 「分支在」不是「卡已认领」:认领是 `Claim:` 评论,分支只是线索;291 个头里 257 个无 PR。
378379
- ⛔ 不据 `ls-remote | grep issue-` 正命中回避该卡:失效方向是活卡被读成已认领,无红信号。
379380
- 会话从上下文检测不到自己的静默降档:横幅只在 UI 侧渲染,上下文零信号。
380381
- 服役档的权威读数是 `get_session`(claude-code-remote MCP,无参)的 `external_metadata.last_served_model`。
@@ -428,7 +429,6 @@
428429
- `check:pm-dispatch-gates` 单机 430–450 秒贴容器上限 ⇒ detach 加 `tail --pid=$!`;超时非读数。
429430
- `check-half-states.mjs` 连 `--help` 都跑整仓 I/O ⇒ 早读到的输出文件是空的,不是干净的。
430431
- 后台工具调用里再 `nohup … &` 会让包装器报假 `exit 0`,而真活还在跑。
431-
- 分支删除被拒有第二形态:代理回 403,与既有 send-pack 断连同处置 ⇒ 不可删,⛔ 不重试。
432432
- harness 按内容拒写:同会话派发 PR 上 PASS 拒为 `[Self-Approval]`;同通道建卡、ACCEPT 照过。
433433

434434
## 闭合关键词解析(PR 正文写侧)

‎content/docs/api/client-sdk.mdx‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,12 @@ console.log(explained.sql, explained.params); // { sql, params }
334334

335335
```typescript
336336
const packages = await client.packages.list();
337-
await client.packages.install({ id: 'com.objectstack.plugin-auth', version: '1.0.0' });
337+
await client.packages.install({
338+
id: 'com.objectstack.plugin-auth',
339+
type: 'plugin',
340+
name: 'Auth Plugin',
341+
version: '1.0.0',
342+
});
338343
await client.packages.enable('com.objectstack.plugin-auth');
339344
await client.packages.disable('com.objectstack.plugin-auth');
340345
await client.packages.uninstall('com.objectstack.plugin-auth');
@@ -767,17 +772,22 @@ try {
767772
if (!isApiError(err) || err.code !== 'FLOW_FAILED') throw err;
768773
const verdict = ResumeFailureDetailsSchema.safeParse(err.details);
769774
if (verdict.success && verdict.data.repairable) {
770-
// verdict.data.status === 'stranded' — verdict.data.runId names the run
771-
// an operator can re-arm; offer that instead of closing as terminal.
775+
// verdict.data.runId names the run an operator can re-arm; offer that
776+
// instead of closing as terminal. Read `repairable` alone: `status` is
777+
// absent on the subflow delegation failure, which is repairable without
778+
// ever carrying the word `stranded`.
772779
}
773780
}
774781
```
775782

776783
`repairable` is always present on the resume door's `400` — `false` on a plain
777784
terminal failure, deliberately, so an absent member reads as an older server
778-
rather than as "not repairable". `status` is the engine's own stamp
779-
(`'stranded'` or `'failed'`), forwarded when the engine set one and never
780-
invented by the door. The code stays `FLOW_FAILED`: there is no `FLOW_STRANDED`
785+
rather than as "not repairable". It is not a reading of `status`: where the
786+
engine stamped one, that stamp decides; where it stamped none — the subflow
787+
delegation failure — the door asks the engine's read-only inspection, so
788+
`repairable: true` can arrive with no `status` beside it. `status` is the
789+
engine's own stamp (`'stranded'` or `'failed'`), forwarded when the engine set
790+
one and never invented by the door. The code stays `FLOW_FAILED`: there is no `FLOW_STRANDED`
781791
sibling, and the trigger door's `400` carries `errorMessage` / `summary` only —
782792
it never resumes, so "repairable" has no referent there.
783793

‎content/docs/automation/flows.mdx‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -790,11 +790,18 @@ per-node `summary`:
790790
"errorMessage": "…", "summary": { … } } } }
791791
```
792792

793-
Branch on `details.repairable`, never on the message: it is `true` exactly when
794-
`status` is `stranded`, and it is **always present** on this route's `400` —
795-
`false` on a plain terminal failure, so an absent member reads as an older
796-
server rather than as "not repairable". `status` is the engine's own stamp,
797-
forwarded when it set one and never invented by the route. The code does not
793+
Branch on `details.repairable`, never on the message. It is **always present**
794+
on this route's `400`, and it is the engine's own answer rather than a reading
795+
of the status word: where the engine stamped a `status`, that stamp decides
796+
(`stranded`); where it stamped none, the route asks the engine's read-only
797+
inspection and relays the verdict. The second case is a real exit, not a
798+
fallback — a subflow **delegation** failure carries no status, because nothing
799+
re-arms an ancestor by resuming it, and yet the ancestor's consumed pause is
800+
journalled and one operator restore re-arms that whole chain, so it answers
801+
`repairable: true` with no `status` beside it. `false` on a plain terminal
802+
failure, so an absent member reads as an older server rather than as "not
803+
repairable". `status` is the engine's own stamp, forwarded when it set one and
804+
never invented by the route. The code does not
798805
change (there is no `FLOW_STRANDED`), and the trigger route's `400` carries
799806
`errorMessage` / `summary` only — it never resumes, so the question has no
800807
referent there. The shape is `ResumeFailureDetailsSchema` in

0 commit comments

Comments
 (0)