Skip to content

Commit 18f95d5

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4570-docs-import-surface
# Conflicts: # content/docs/references/api/connector.mdx # content/docs/references/api/rest-server.mdx
2 parents b8d5b11 + b9b7310 commit 18f95d5

28 files changed

Lines changed: 683 additions & 156 deletions

.changeset/adr-anchors-guard.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
---
3+
4+
Tooling-only: `pnpm check:adr-anchors` — code an accepted ADR governs must keep naming it (#3723 follow-up). Adds `scripts/check-adr-anchors.mjs` + `scripts/adr-anchors.json` (8 seeded anchors, all in the blast radius of the incident), a `Lint & Type Check` step, and Prime Directive #13 in `AGENTS.md` ("an accepted ADR binds until a superseding ADR says otherwise"). Releases nothing — no package changes.
5+
6+
The incident this closes: three accepted ADRs said `sys_member.role` must never carry RBAC authority, and a patch-level changeset made app-declared names storable there anyway; a follow-up then made it automatic in every host. The mechanism was not carelessness — the file being edited never named the ADRs that governed it, so the author could not have known. The check is a presence check (does the governed file still reference its ADR ids?), deliberately dumb; the value is that the failure carries the **invariant**, not just an id to paste back, and it fires on exactly the diff that warrants a second look — someone rewriting a governed block and dropping the rationale with it.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
A blueprint `formula` field can finally say what it computes: `BlueprintFieldSchema` and its OpenAI-strict mirror both gain `expression`.
6+
7+
`BlueprintFieldSchema.type` is the **full** `FieldType` enum, so the AI-build design step could always NAME a `formula` field — but neither the lenient schema nor the strict mirror the model generates against had any key for the body. There was no way, anywhere on that surface, to state what the formula computed. It materialized bare, and cloud's graph-lint then correctly reported `formula_without_expression` with the fix *"Set field expression to a CEL formula"* — a fix the agent could not write in the blueprint it was holding. Detected, but unfixable on the surface that produced it.
8+
9+
This is the exact hole `summaryOperations` closed for roll-ups in cloud#970 (see this file's own test: *"z.object STRIPS unknown keys, so before this slot existed a blueprint that correctly declared `{ type:'summary', summaryOperations:{…} }` lost the config at the parse waist and materialized runtime-dead"*). `formula` was simply left behind — the same defect, one field type over.
10+
11+
It bites hardest through `nameField`, whose own guidance tells the model to point at a formula for numbered entities (invoice/ticket) that compose `number · name`. Without an expression slot, following that advice produces a record title that is blank on every card, lookup chip and breadcrumb.
12+
13+
**The pin matters more than the key.** A1's root cause is not a forgotten property — it is that two schemas describe the same shape and nothing forced them to agree. The mirror is what the model may EMIT; the lenient schema is what downstream READS. Drift in either direction silently drops authored config. A new test asserts the two field schemas carry **exactly** the same keys, so the next key added to one cannot go missing from the other.
14+
15+
Cloud's `objectBody` carries the value through to materialization (companion change in the `cloud` repo); it reads the key via cast, as it already does for `defaultValue`, so it is inert against an older spec and live as soon as this ships.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
---
3+
4+
fix(ci): hand the cross-repo token to github-script instead of requiring @actions/github
5+
6+
Release-nothing: touches `.github/workflows/cross-repo-issue-closer.yml` only.
7+
8+
`require('@actions/github')` is not resolvable from a github-script `script:`
9+
block — the action bundles its dependencies, so the call fails at runtime with
10+
`MODULE_NOT_FOUND`. The token is now handed to the action itself
11+
(`github-token:`), which makes the injected `github` client the cross-repo one,
12+
with `secrets.GITHUB_TOKEN` as the fallback so the report path can still
13+
comment on the pull request when no cross-repo credential is configured.
14+
15+
Observed in objectui, whose copy of this workflow reached that line first. Its
16+
run also confirmed the credential logging added alongside works, printing
17+
`CROSS_REPO_ISSUE_TOKEN: configured` before failing at the require.
18+
19+
This supersedes #4573, which renamed the second client without removing it —
20+
the rename fixed the identifier collision that aborted parsing, and only then
21+
did the run get far enough to hit the unresolvable module. Three failures in
22+
three consecutive runs, each one further down the same script: parse, resolve,
23+
then (expected next) the API calls themselves.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
Internal agent tooling only (`.claude/` pm-dispatch escalation bar + API-body generics trap) — releases nothing.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): a flow save that skipped canonicalization says so (#4580)
6+
7+
`saveMetaItem` canonicalizes flow bodies before the schema gate (#4542). When the
8+
canonicalizer throws — it is stricter than the gate: strict parse, cycle
9+
detection, control-flow region validation — the save falls back to the raw body
10+
so a work-in-progress draft with a temporary cycle stays saveable. That fallback
11+
is correct and unchanged. It was also completely silent.
12+
13+
Of the four postures at this seam, three announce themselves: a clean
14+
canonicalization heals the row, a refused rename fails with `409
15+
FLOW_CONVERSION_CONFLICT` naming the token, and a host with no automation service
16+
is reported by `os migrate meta --stored`. The throw-fallback said nothing, so a
17+
save that skipped canonicalization was indistinguishable from one that healed the
18+
row — and a body that is *both* a legacy dialect and unparseable by the strict
19+
canonicalizer re-persisted verbatim. That is the exact #4542 symptom, arriving
20+
silently, while the boot warning for legacy stored rows tells the author that
21+
re-saving is the remedy.
22+
23+
The fallback now emits a `console.warn` naming the flow and the canonicalizer's
24+
own error, deduped once per flow per process (the `convertStoredItem` pattern —
25+
Studio autosaves the same draft repeatedly, and a WIP cycle throws on every
26+
write). This aligns the write seam with ADR-0087 D2's "loud" posture, where
27+
conversions emit notices, reads warn once per row, and `migrateStoredMetadata`
28+
reports `failed` with the message.
29+
30+
No behavior change: the body still saves, the schema gate stays the arbiter, and
31+
`registerFlow` still refuses to arm a malformed flow. Refusing the save in
32+
publish mode was considered and rejected — publish is the default mode, so it
33+
would silently tighten validation for every existing caller, and it could only be
34+
enforced on hosts that have an automation service, making the same body saveable
35+
on a control-plane host and a 422 on an automation host.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: `@objectstack/spec/api` no longer exports the bare names `WebhookConfig` / `WebhookEvent` — they belong to `./integration` alone (#4572)
6+
7+
The names `WebhookConfig(Schema)` / `WebhookEvent(Schema)` resolved to **two
8+
different declarations** depending on the import path (`./api` vs
9+
`./integration`) — the #4411 dual-source trap, and a cross-form one:
10+
`./api`'s `WebhookEventSchema` was a `z.object` (an OpenAPI 3.1 webhook
11+
*definition* descriptor: `name`/`description`/`method`/`payloadSchema`/
12+
`security`) while `./integration`'s is a `z.enum` of connector event types
13+
(`'record.created'``'rate_limit.exceeded'`). Auto-importing the wrong side
14+
compiled and validated the wrong contract. Resolution (three-repo,
15+
import-statement-level consumer scan: framework, cloud, objectui):
16+
17+
- **Removed** `WebhookConfigSchema` / `WebhookConfig` from
18+
`@objectstack/spec/api`. This pair was dead: wired into nothing — not even
19+
`RestServerConfigSchema` — with zero import-level consumers in all three
20+
repos, and no runtime ever read a REST-server webhook config.
21+
- FROM `import { WebhookConfig } from '@objectstack/spec/api'`
22+
TO: no replacement exists for a REST-server webhook config (it never had a
23+
runtime). For a real outbound webhook use `Webhook` from
24+
`@objectstack/spec/automation`; for a connector webhook use
25+
`WebhookConfig` from `@objectstack/spec/integration` (a **different
26+
shape**: it extends the canonical automation `WebhookSchema` with
27+
`events` / `signatureAlgorithm`, and has no `deliveryConfig` /
28+
`registrationEndpoint` / `enabled`).
29+
- **Renamed** `WebhookEventSchema` / `WebhookEvent` in `@objectstack/spec/api`
30+
`OpenApiWebhookEventSchema` / `OpenApiWebhookEvent` (same shape, rename
31+
only; joins the existing `OpenApi*` family). `OpenApi31ExtensionsSchema.webhooks`
32+
now references the renamed schema — its parsed/authored shape is unchanged.
33+
- FROM `import { WebhookEvent } from '@objectstack/spec/api'`
34+
TO `import { OpenApiWebhookEvent } from '@objectstack/spec/api'` (if you
35+
meant the OpenAPI 3.1 webhook descriptor), or
36+
`import { WebhookEvent } from '@objectstack/spec/integration'` (if you
37+
meant the connector event enum — check which shape you actually consume:
38+
object vs string enum).
39+
- `@objectstack/spec/integration`'s `WebhookConfig(Schema)` /
40+
`WebhookEvent(Schema)` are **unchanged** and are now the sole owners of the
41+
bare names. Imports from `./integration` need no migration.
42+
43+
`dual-source-exports.baseline.json` shrinks by exactly these 4 rows (35 → 31,
44+
#4535 C1).

.claude/agents/os-dev.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,23 @@ rules that most often get missed:
3333
consumer (`??` alias, tolerant parse), the bug is at the producer or in the
3434
spec — fix it there, or return `needs_decision`.
3535

36+
**Resource discipline — parallel agents share ONE container; unbounded
37+
build/test runs OOM it.** Binding rules:
38+
39+
1. **Serialize the heavy phase.** Wrap every build and test run in the shared
40+
verification lock, so editing parallelizes but memory peaks never stack:
41+
`flock -w 7200 /tmp/os-heavy-verify.lock -c '<build/test command>'`
42+
(one lock file per container; waiting on it is normal, not a hang).
43+
2. **Cap the heap.** Prefix heavy commands with
44+
`NODE_OPTIONS=--max-old-space-size=4096` (raise only with a reason).
45+
3. **Scope, don't sweep.** Build and test the affected packages
46+
(`pnpm --filter <pkg> build/test`), not the whole repo, unless the task
47+
explicitly requires a full pass. Cap test parallelism:
48+
vitest `--maxWorkers=2`, turbo `--concurrency=2`.
49+
4. **Clean up when done**: after the PR is up, remove your worktree
50+
(`git worktree remove <path> --force`) — leftover `node_modules` trees
51+
exhaust the container's disk, which fails as confusingly as OOM.
52+
3653
Definition of done, in order:
3754

3855
- Implementation matches the issue's acceptance criteria.
@@ -89,3 +106,10 @@ Final message — exactly this JSON, no prose around it:
89106

90107
Use `status: "rework"` for a partial result you know is incomplete (say why in
91108
`summary`); the PM will review and re-dispatch with feedback.
109+
110+
Practical trap when filing issues/PRs through the GitHub API: the body
111+
sanitizer strips `<` followed by a letter as an HTML tag **at rest**, which
112+
destroys TypeScript generics (`Assert<Equal<1, 2>>` is stored as `Assert>`).
113+
Write generics with a space after each `<``Assert< Equal< 1, 2 > >` is
114+
still valid TypeScript — and read the stored body back to verify when a
115+
snippet is load-bearing.

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

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,23 @@ known case: accepting a `repo:objectui` PR ⇒ file a `pm:queue` issue in
102102
`objectstack` — "run `pnpm objectui:refresh` and land the console bump",
103103
referencing the merged PR, blocked-by it until it actually merges.
104104

105-
**4. One board, no second tracker.** The pm labels above are the state
105+
**4. Multiple PM sessions shard by repo — never share one queue.** The
106+
claim protocol makes concurrent PMs *safe*, not *useful*: batch
107+
independence (file-disjointness) is only checked within one PM's view, so
108+
two PMs on the same queue can claim different issues that collide on
109+
shared files, and the merge queue is one lane regardless. Scaling order:
110+
111+
1. One PM, bigger batch (`batch:5` is the maintainer's chosen operating
112+
point, riding on the resource discipline above), heavy tasks via
113+
`mode:cloud` — adds compute without adding schedulers.
114+
2. When one PM genuinely can't keep up: a second session takes a **whole
115+
repo** as its shard (`/pm-dispatch repo:objectstack-ai/objectui`) —
116+
file universes are disjoint by construction. A sharded PM states its
117+
shard in every claim comment and **never claims outside it**; cross-repo
118+
parent/sub-issue chains stay with the main-backlog PM.
119+
3. Multiple PMs on the SAME queue: prohibited — all cost, no throughput.
120+
121+
**5. One board, no second tracker.** The pm labels above are the state
106122
machine; an org-level GitHub Project pulling issues/PRs from all three repos
107123
gives the maintainer a single view (filter by `repo:*` and `pm:*`). The PM
108124
maintains no tracking state outside GitHub — that invariant is what keeps
@@ -201,6 +217,16 @@ execute atomically, in order:
201217
Dev agents push their branch early — a remote branch is the hardest evidence
202218
of work in flight, closing the gap between "claimed" and "PR exists".
203219

220+
**Multiple GitHub accounts (colleagues' Claude Code sessions) simplify
221+
this, not complicate it.** Across accounts the assignee alone already says
222+
*who*: `assignee isn't you → taken, never touch` is the entire cross-account
223+
protocol, and it's already the rule. The claim-comment ritual (branch name,
224+
round, race check) matters *within* one account's sessions. When several
225+
accounts work the backlog, partition it the same way as multi-PM sharding —
226+
by repo or by an agreed label per account — and record the assignment table
227+
once in a pinned issue or the round report so nobody triages another
228+
account's shard.
229+
204230
**Stale-claim reclaim**: a claim older than ~24 h whose promised branch does
205231
not exist on the remote and has no PR is presumed dead — comment asking, and
206232
after another window of silence, remove the assignee (note why) and return
@@ -242,6 +268,19 @@ Follow your operating procedure (you are the os-dev agent). Non-negotiables:
242268
Return ONLY the JSON report defined in your agent definition.
243269
```
244270

271+
#### Resource limits — parallel agents share ONE container
272+
273+
Memory peaks come from **build + test**, not editing, so the fix is not less
274+
parallelism but serialized heavy phases: the os-dev definition requires every
275+
build/test run to hold the container-wide verification lock
276+
(`flock /tmp/os-heavy-verify.lock`), a `NODE_OPTIONS=--max-old-space-size`
277+
heap cap, scoped `--filter` builds/tests, capped vitest/turbo workers, and
278+
worktree cleanup after the PR is up. PM-side: treat `batch:3` as assuming
279+
normal-sized tasks — for build-heavy ones (dependency-family upgrades, full
280+
regression passes) drop to `batch:2`, or dispatch that issue via
281+
`mode:cloud` so it gets its own container. If an agent dies with a
282+
heap/OOM signature, redispatch it alone rather than into a full batch.
283+
245284
#### Dispatch backends
246285

247286
**`mode:subagent` (default).** The `Agent` tool, as described above. The devs
@@ -313,8 +352,36 @@ Verdict per issue:
313352

314353
### 8. Escalate uncertainties to the maintainer
315354

316-
Whenever a dev returns `needs_decision`, an issue is too vague to dispatch, or
317-
rework has failed twice:
355+
**First, apply the escalation bar — most things that FEEL like decisions are
356+
not.** The maintainer's words: 「明显的问题直接修,不是事事都需要我确认」.
357+
Escalate ONLY when at least one of these holds:
358+
359+
- the options genuinely diverge on **product semantics or public contract
360+
shape** and neither the issue, AGENTS.md, ADRs, nor existing code norms
361+
determines the answer;
362+
- the fix requires a **destructive or hard-to-reverse action** (stored-data
363+
migration shape, deleting a shipped capability, force operations).
364+
365+
Everything else is the PM's call — decide, dispatch, and give the maintainer
366+
a **veto window instead of a permission gate**: state what you decided and
367+
why in the issue comment and the round report; they can stop it, but you do
368+
not wait for them. Named non-escalation classes (act immediately):
369+
370+
- **Restore-invariant fixes.** When the repo already states the invariant —
371+
one contract version across the family, declared = enforced, a gate must
372+
actually compile/run what it claims to check — a finding that the
373+
invariant is broken carries its own decision. A dual-version dependency
374+
graph, an inert tripwire, an unwired gate: queue it, dispatch it, report
375+
it. Asking "may I restore the invariant?" is the anti-pattern.
376+
- **Sequencing and dependency ordering** between technical tasks.
377+
- **Verification strategy** (what regression pass a risky-but-decided change
378+
needs) — that is scoping the work, not deciding it.
379+
- A dev's `needs_decision` that, on PM review, falls into the classes above:
380+
answer the dev yourself with the decision and rationale; do not relay it
381+
upward.
382+
383+
Whenever a dev returns `needs_decision` that passes the bar above, an issue
384+
is too vague to dispatch, or rework has failed twice:
318385

319386
1. **Default: the decision lives ON the issue it belongs to — never a new
320387
issue.** Post the analysis as a comment on that issue, add the

.github/workflows/cross-repo-issue-closer.yml

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ jobs:
4646
# to the repository running the workflow, which is the whole problem.
4747
CROSS_REPO_TOKEN: ${{ secrets.CROSS_REPO_ISSUE_TOKEN }}
4848
with:
49+
# Hand the cross-repo token to the action itself, so `github` IS the
50+
# cross-repo client. `require('@actions/github')` does NOT work here:
51+
# github-script bundles its dependencies and the module is not
52+
# resolvable from the script scope (`MODULE_NOT_FOUND`). Falling back
53+
# to GITHUB_TOKEN keeps the report path able to comment on this PR.
54+
github-token: ${{ secrets.CROSS_REPO_ISSUE_TOKEN || secrets.GITHUB_TOKEN }}
4955
script: |
5056
const body = context.payload.pull_request.body || '';
5157
const prUrl = context.payload.pull_request.html_url;
@@ -109,27 +115,23 @@ jobs:
109115
return;
110116
}
111117
112-
// A second client: `github` is bound to GITHUB_TOKEN, which has no
113-
// write access outside this repository.
114-
const crossRepo = require('@actions/github').getOctokit(token);
115-
116118
for (const [key, t] of targets) {
117119
try {
118-
const { data: issue } = await crossRepo.rest.issues.get({
120+
const { data: issue } = await github.rest.issues.get({
119121
owner: t.owner, repo: t.repo, issue_number: t.number,
120122
});
121123
if (issue.state === 'closed') {
122124
core.info(`${key} is already closed — skipping.`);
123125
continue;
124126
}
125-
await crossRepo.rest.issues.createComment({
127+
await github.rest.issues.createComment({
126128
owner: t.owner, repo: t.repo, issue_number: t.number,
127129
body:
128130
`已由 ${thisRepo} 的 ${prUrl} 修复并合并。\n\n` +
129131
`(跨仓库的关闭关键字不会自动生效,本条由 \`cross-repo-issue-closer\` 工作流代为收口。)\n\n` +
130132
`---\n_Generated by [Claude Code](https://claude.ai/code)_`,
131133
});
132-
await crossRepo.rest.issues.update({
134+
await github.rest.issues.update({
133135
owner: t.owner, repo: t.repo, issue_number: t.number,
134136
state: 'closed', state_reason: 'completed',
135137
});

0 commit comments

Comments
 (0)