Skip to content

Commit bbad9af

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4587-metadata-event-dual-source
2 parents 0281703 + b3235b5 commit bbad9af

25 files changed

Lines changed: 1174 additions & 15 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
---
3+
4+
Tooling-only: expand `scripts/adr-anchors.json` from 8 to 15 anchors — the bounded authz/security ADR sweep promised in #4575. Releases nothing.
5+
6+
The first 8 anchors covered only #3723's blast radius. This sweep audited the decisions in ADR-0057 / 0066 / 0068 / 0090 / 0091 / 0095 / 0105 for other load-bearing realizations — places where a reasonable engineer could "fix" the code and be reverting a decision. Seven files added, each with its reversal story:
7+
8+
- `posture-ladder.ts` (0095) — posture derives from capability grants; re-reading the better-auth role reopens the #2836 dual-track class.
9+
- `grant-validity.ts` (0091) — window enforcement lives at resolution time; "optimize it into a cleanup job" is the banned move (ADR-0049).
10+
- `tenant-layer.ts` (0095 D1) — Layer 0 shares no compiler/merge/bypass with business RLS; "deduplicate into the RLS compiler" would let a Layer-1 change weaken tenant isolation.
11+
- `auto-org-admin-grant.ts` (0105) — wall-less postures get `organization_admin_no_bypass`; "why two sets?" collapses into an environment-wide superuser (the F2 finding).
12+
- `invitation-placement.ts` (0105 D8) — issuance dry-runs the gate; "acceptance re-checks anyway" is false (acceptance runs under system context) and skipping it is an escalation hole.
13+
- `position.zod.ts` (0090 D3) — positions are flat; adding `parent` is the exact mistake ADR-0057 D5 retired.
14+
- `permission-evaluator.ts` (0066 D2 + 0057 D1) — superuser bypass derives from the wildcard grant, no stored boolean and no role fast-path.
15+
16+
Plus one extension: the existing `resolve-authz-context.ts` anchor gains ADR-0068 (`platform_admin` is derived from an unscoped grant — no trusted stored boolean, the classic "add an `is_admin` column" reversal target).
17+
18+
All 15 pass as-is — every anchored file already cited its governing ADRs — so this changes zero code, only registers what must not be silently un-cited. Negative-tested by stripping `ADR-0091` from `grant-validity.ts` (fails, printing the invariant).

.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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/runtime": patch
3+
"@objectstack/observability": patch
4+
---
5+
6+
fix(runtime): declarative `defineJob` cron jobs are actually scheduled (#4567)
7+
8+
Every background job authored as `defineJob({ schedule: { type: 'cron', … } })`
9+
was **silently never scheduled**. `JobSchema.parse` rewrites the cron
10+
`expression` into the canonical expression envelope
11+
(`{ dialect: 'cron', source: '0 1 * * *' }` — the authoring/persistence tier),
12+
but `AppPlugin` handed `job.schedule` verbatim to `IJobService.schedule`, whose
13+
boundary contract documents `expression` as a **bare cron string** because
14+
`CronJobAdapter` passes it straight to croner. croner rejected the object
15+
(`CronPattern: Pattern has to be of type string.`), the throw was swallowed by a
16+
per-job `try/catch` that only `warn`ed, and the author saw a green build and a
17+
green boot with the job never running. `interval` / `once` schedules and
18+
flow `schedule` triggers were unaffected.
19+
20+
**Fix (contract-first).** The authoring→boundary downgrade now happens at the one
21+
place the two tiers meet — `AppPlugin`'s declarative-job registration, alongside
22+
the existing `retryPolicy` / `timeout` threading — via
23+
`toBoundaryJobSchedule()`. The adapters stay strict: no `typeof === 'object'`
24+
tolerance was added downstream, so the boundary keeps exactly one shape.
25+
A schedule that cannot be reduced to it (unknown type, AST-only or non-`cron`
26+
expression envelope, missing `intervalMs` / `at`) is rejected by name.
27+
28+
**The failure path is no longer silent.** A job that cannot be scheduled now logs
29+
at **error** level with its own message (`Background job FAILED TO SCHEDULE — it
30+
will never run`), plus a boot summary line when any job failed, and increments
31+
the new `job_schedule_failures_total` counter
32+
(`SEMCONV.jobScheduleFailuresTotal`, labels `app` / `job`) on the observability
33+
metrics registry. "Failed to schedule" no longer shares the quiet `warn` used by
34+
"handler not found in bundle.functions" — the first is an outage of declared
35+
work, the second is a job that was never going to run.
36+
37+
No authoring change is required: existing `defineJob` cron declarations start
38+
working on upgrade.

.changeset/duplicate-fix-guard.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
---
3+
4+
ci: fail a PR at open time when an earlier open PR already declares a fix for
5+
the same issue (#4588)
6+
7+
Release-nothing: adds `.github/workflows/duplicate-fix-guard.yml` and updates
8+
agent process docs (AGENTS.md, CLAUDE.md, pm-dispatch claim template) — no
9+
package code.
10+
11+
GitHub lets any number of open PRs declare `Fixes #N` for the same issue.
12+
On 2026-08-02, #4555 and #4559 both declared `Fixes #4551` and both were
13+
implemented in full — 834 duplicate lines through the whole gate suite — with
14+
the duplication machine-detectable from the second PR's open (03:08) yet
15+
unnoticed by any human until 08:52. The shared GitHub identity made the
16+
issue's assignee useless as a warning: "assigned to os-zhuang" reads the same
17+
whether the claimant is you or another session.
18+
19+
Three changes, one per hole:
20+
21+
- **Duplicate Fix Guard workflow**: on PR opened/edited/reopened/synchronize,
22+
parse same-repo closing keywords and fail the PR if an EARLIER open PR
23+
(lower number) declares the same issue, naming it. First come, first
24+
served — matching the pm-dispatch "first claim comment wins" convention.
25+
The check is body-driven and re-runs on `edited`, so a red PR goes green
26+
the moment the conflict is resolved either way.
27+
- **Claim comments must carry a session ID** (pm-dispatch template, AGENTS.md,
28+
CLAUDE.md): under a shared identity, the comment's session line is the only
29+
thing that makes "is this claim mine?" answerable.
30+
- **Branch naming `claude/issue-<n>-<slug>`** (AGENTS.md): puts the issue
31+
number where `git ls-remote | grep issue-<n>` can find it; the workflow
32+
warns (never fails) when a fix PR's branch names no declared issue.
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.

.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: 79 additions & 6 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
@@ -189,18 +205,34 @@ execute atomically, in order:
189205
1. **Assign** to yourself (`@me`) and add `pm:dispatched`. Skip — and drop
190206
from the batch — any issue that acquired an assignee since step 1.
191207
2. **Claim comment** (Chinese), fixed shape — the branch name is the key,
192-
every later artifact (worktree, push, PR) hangs off it:
208+
every later artifact (worktree, push, PR) hangs off it. The session ID is
209+
NOT optional: under the shared identity it is the only line that lets a
210+
later reader — including your own future self after a context reset —
211+
answer "is this claim mine?". A claim without it caused the #4555/#4559
212+
duplicate (#4588): the second session saw its own shared name as assignee
213+
and could not tell the claim was someone else's.
193214
> 认领:PM 循环第 N 轮
215+
> 会话:`session_<id>`
194216
> 分支:`claude/issue-<n>-<slug>`
195217
> Worktree:`<repo>-issue-<n>`
196218
3. **Race check**: assignment is idempotent, so two agents can both
197219
"succeed". Re-read the comments; if an earlier claim comment with a
198-
*different* branch name exists, you lost — touch nothing of theirs,
199-
reply 「已有认领,让行」, and pick another issue. First comment wins.
220+
*different* session ID or branch name exists, you lost — touch nothing of
221+
theirs, reply 「已有认领,让行」, and pick another issue. First comment wins.
200222

201223
Dev agents push their branch early — a remote branch is the hardest evidence
202224
of work in flight, closing the gap between "claimed" and "PR exists".
203225

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

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

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

314359
### 8. Escalate uncertainties to the maintainer
315360

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

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

0 commit comments

Comments
 (0)