Skip to content

ci(reaper): guard the MERGED bucket on base.ref === 'main', with its contract harness - #15144

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-13503-reaper-base-ref-guard
Sep 4, 2026
Merged

baozhoutao merged 1 commit into
mainfrom
claude/issue-13503-reaper-base-ref-guard

Conversation

@claude

@claude claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Part of #13503

⛔ Not Fixes — deliberately. This is PR 1 of the ruling only. The workflow_dispatch dry-run, the deletion list posted on the card, the maintainer's release line and the deletion run are all still ahead, and a closing keyword here would silently close a card that is still waiting on a human.

What the ruling asked for

Ruling of 2026-09-03 on #13503 (comment 5529423421, decision batch #24, maintainer 「其他同意」 adopting option A):

the reaper's classifier gains a base-ref guard (pr.base.ref === 'main' for the MERGED bucket) with its self-test row — that is the one measured shape gap, and it lands in the standing reaper so the guard is enforced, not remembered

That is this PR, and nothing else from that ruling.

The defect

merged_at says a pull request landed. It does not say where. A stacked PR whose base is another dev branch reports merged_at exactly like one that landed on main, and its content reaches main only if that base branch itself later merged — a second hop the reaper's deliberately flat criterion cannot see and must not start guessing at.

Measured in the #13503 census (2026-09-02, comment 5511828156) over the 678 copilot/* branches: of 575 MERGED, 48 (8.3%) merged into something other than main. Walking all 48 — 43 have a base that itself later merged to main (content lands, via a 2-hop chain), 4 have a base that no longer exists and could not be re-verified, and 1 — copilot/check-action-run-status, based on a PR that closed without merging — is a MERGED branch whose content has no confirmed path to main at all. Against that one, the unguarded criterion says "safe to delete", and it is wrong.

The change

.github/workflows/merged-branch-reaper.yml, in the standing classifier:

  • reapable now additionally requires that one of the branch's merged PRs reports base.ref === 'main'.
  • Everything else that merged goes to a new mergedElsewhere bucket — its own table row, its own listing naming the base each branch merged into, its own step output. Held for a human look, never reaped, including the 43 that are safe by chain. That asymmetry is deliberate and is argued in the header: an unreaped branch costs a line of noise, a wrongly reaped one costs work that exists nowhere else.
  • A merged PR record carrying no base at all is counted as NOT main — the fail-closed direction.
  • The grace window still runs off the newest merge of all merged PRs, unchanged. If the guard moved that, it could reap something earlier than the unguarded script would, which is the one direction a narrowing guard must not have.

⛔ It only narrows, and that is asserted, not promised

reapable under this diff is a subset of reapable before it, for every input. New scripts/check-merged-branch-reaper-outcome.mjs holds it as an invariant over every scenario:

reaped(branch)  =>  that branch has a merged PR whose base is `main`

stated as a consequence rather than as the presence of a line of code, so a later rewrite that keeps the behaviour passes and one that loses it cannot.

⛔ No is-ancestor, anywhere

The workflow header's line 12 constraint is respected, not replaced. The base ref is read off the PR record, which is the instrument that section mandates; nothing here asks git whether a commit is reachable from anything. The MERGED-state criterion is unchanged — the guard sits on top of it.

The self-test row the ruling names

There was no existing self-test on this classifier to add a row to — the reaper's logic is inline actions/github-script and nothing in the tree drove it. So the row lands in this repo's established harness shape for exactly that situation, the third of its kind: check-cross-repo-closer-outcome.mjs and check-merge-queue-triage-outcome.mjs are the two siblings, and this follows them method-for-method — the shipped script is extracted from the YAML with a real parser, never retyped, and run under doubles the way the action runs it, as one AsyncFunction body.

11 scenarios (node scripts/check-merged-branch-reaper-outcome.mjs --list). The guard's own rows:

row fixture outcome
G1 merged into main, 30d ago reaped — the guard costs the base case nothing
G2 merged into a sibling branch held, never reaped, named in the report with its base
G3 the census's one measured false positive — merged into a base whose own PR closed unmerged held — unguarded, this is the branch the reaper would have deleted
G4 a merged PR record with no base at all held — fail-closed
G5 merged into main and later into a sibling still reaped, and grace still measured from the newest merge
G6 merged into main inside the grace window held by grace, not by the guard — the report must say the right reason

plus four invariants checked for every scenario (narrowing, the held bucket is really merged-and-not-into-main, the buckets partition the candidates, the held count is actually rendered), and the pre-existing decisions the guard sits beside: open-PR precedence, the grace window, the defensive head-ref filter, the protected short-circuit.

--self-test mutates the extracted source 12 ways and requires the battery to redden for each, naming the scenario that catches it: the guard deleted, the guard blinded to the base, the base constant renamed, the fail-closed direction flipped, the bucket unrecorded, its table row deleted, its listing deleted, its step output dropped — and one mutation per neighbouring decision. Each mutation asserts its own anchor was present first, so a rewrite of the workflow cannot leave the mutations silently matching nothing.

⚠️ The mutations run against an in-memory copy of the extracted source. Nothing is written to the workflow file at any point, so there is no restore leg that can leave a mutated tree behind.

Wiring

.github/workflows/lint.yml, beside its two sibling harnesses, direct node form per that file's GATE INVOCATION IDIOM note:

node scripts/check-merged-branch-reaper-outcome.mjs --self-test
node scripts/check-merged-branch-reaper-outcome.mjs

check:self-test-wired and check:self-test-workflow-commands both pick it up from there (167 scripts, all green), and the self-test asserts its own wiring in lint.yml so removing the step reddens the gate.

⚠️ merged-branch-reaper.yml keeps its dispatch-gates: no-check-families declaration. That marker answers "do this workflow's own steps discover a family", and the answer is still no — the gate runs in lint.yml, not here. A card touching the reaper still gets the gate scheduled, because the gate names that path in its own source, which is how dispatch-gates derives a family. Verified: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack lists check-merged-branch-reaper-outcome for this diff.

⛔ What this PR does NOT do

⚠️ One gap for whoever picks up the dry-run phase: the ruling calls for "one workflow_dispatch dry-run over the copilot/ prefix", and workflow_dispatch currently has no prefix input — PREFIX is the hard-coded claude/. Adding one is out of scope for PR 1 and is flagged rather than done here.

Verification

All exit codes captured before any pipe. Run on the final commit 45ca05049.

  • node scripts/check-merged-branch-reaper-outcome.mjs → exit 0, OK (68 assertions over 11 scenarios, driving the 8980-char classifier extracted from .github/workflows/merged-branch-reaper.yml)
  • node scripts/check-merged-branch-reaper-outcome.mjs --self-test → exit 0, 63 assertions, 12 mutations of the shipped script each driven to red
  • check:self-test-wired (+ --self-test), check:self-test-workflow-commands, check:step-collectors, check:required-contexts, check:workflow-status-functions, check:ci-filter-parity, check:pm-dispatch-gates, check:nul-bytes → all exit 0
  • derived family for the real diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, its own change-set derivation, not a hand-made list) → 119 families; the ones this diff genuinely touches were run: entry-guard, watch-hint-literal, declared-population-live, agent-test-spelling, single-claim-paths, published-list-mirrors, ratchet-remedy-authority, parse-guard, keyed-text-bounds, comment-mask-corpus, whole-set-label-write, aggregator-roster, closing-keyword-parity, pnpm-acquisition, pnpm-filter-targets, swallow-census-controls, stall-guard-budget, stall-guard-headroom → all exit 0
  • pnpm lint (repo-wide eslint . --no-inline-config, not a narrowed subset) → exit 0

No changeset: .github/workflows/** and scripts/** publish nothing from any package. skip-changeset.


Generated by Claude Code

…s contract harness

`merged_at` says a pull request landed; it does not say WHERE. A stacked PR
merged into another dev branch reports `merged_at` exactly like one that landed
on `main`, and its content reaches `main` only if that base branch itself later
merged -- a second hop the reaper's deliberately flat criterion cannot see.

Measured in the #13503 census (2026-09-02) over 678 `copilot/*` branches: of 575
MERGED, 48 merged into something other than `main`; 43 of those have a base that
itself later merged, 4 have a base that no longer exists, and 1 --
`copilot/check-action-run-status`, based on a PR that closed WITHOUT merging --
has no confirmed path to `main` at all. Unguarded, the classifier calls that one
safe to delete.

`reapable` now additionally requires that one of the branch's merged pull
requests reports `base.ref === 'main'`. Everything else that merged lands in a
new `mergedElsewhere` bucket: rendered in full with the base it merged into,
never reaped. A PR record carrying no `base` is counted as NOT main -- the
fail-closed direction, because the cost of guessing here is a deleted branch.

The guard only NARROWS, and that is asserted rather than promised.
`scripts/check-merged-branch-reaper-outcome.mjs` extracts the shipped script
from the YAML with a real parser, runs it under doubles the way
actions/github-script runs it, and holds four invariants over every scenario --
`reaped => the branch has a merged PR based on main`, the held bucket really is
merged-and-not-into-main, the buckets partition the candidates, and the held
count is rendered. Its `--self-test` deletes the guard, inverts it, renames the
base constant, flips the fail-closed direction and unrecords/unrenders the
bucket, and requires the battery to redden for each; it mutates the decisions
the guard sits beside too (open-PR precedence, the grace window, the defensive
head-ref filter, the protected short-circuit), because a guard that cost
something already there would not be a narrowing.

No `is-ancestor` is introduced anywhere: the base ref is read off the PR record,
which is the instrument the workflow header mandates. The grace window still
runs off the newest merge of ALL merged PRs, so the guard cannot shorten it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 4, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 4, 2026 03:36
@baozhoutao
baozhoutao enabled auto-merge September 4, 2026 03:37
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit 627ea51 Sep 4, 2026
37 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-13503-reaper-base-ref-guard branch September 4, 2026 04:05
baozhoutao pushed a commit that referenced this pull request Sep 4, 2026
…aude/ branches

Flips .github/workflows/merged-branch-reaper.yml from report-only to the
scheduled weekly deletion the maintainer ruled on 2026-09-04 (issue #12771,
decision batch #30), reaffirming the 2026-08-31 ruling under the base-ref
guard PR #15144 landed.

Deletion is a SEPARATE job (`reap`), because `permissions:` is scoped per
job. `sweep` keeps `contents: read` + `pull-requests: read` and remains
structurally incapable of deleting a ref; `reap` holds the only
`contents: write` in the file, consumes the `reapable` list `sweep`
publishes as a job output, and computes no classification of its own.

Fences:
- `reap` never runs on `pull_request` — the self-exercising run stays a dry
  run — and its `if:` is an allowlist of `schedule` plus a
  `workflow_dispatch` on which the operator explicitly set `dry_run: false`.
- the new `dry_run` workflow_dispatch input defaults to true, so the manual
  path is fail-closed.
- the base-ref guard, `PREFIX`, `BASE_REF`, the grace window, the schedule
  and the `is-ancestor` prohibition are all untouched.
- the whole deletion list is printed to the run log before the first delete.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
zhuangjianguo pushed a commit that referenced this pull request Sep 4, 2026
…aude/ branches (#15224)

* ci(reaper): arm the merged-branch reaper for scheduled deletion of claude/ branches

Flips .github/workflows/merged-branch-reaper.yml from report-only to the
scheduled weekly deletion the maintainer ruled on 2026-09-04 (issue #12771,
decision batch #30), reaffirming the 2026-08-31 ruling under the base-ref
guard PR #15144 landed.

Deletion is a SEPARATE job (`reap`), because `permissions:` is scoped per
job. `sweep` keeps `contents: read` + `pull-requests: read` and remains
structurally incapable of deleting a ref; `reap` holds the only
`contents: write` in the file, consumes the `reapable` list `sweep`
publishes as a job output, and computes no classification of its own.

Fences:
- `reap` never runs on `pull_request` — the self-exercising run stays a dry
  run — and its `if:` is an allowlist of `schedule` plus a
  `workflow_dispatch` on which the operator explicitly set `dry_run: false`.
- the new `dry_run` workflow_dispatch input defaults to true, so the manual
  path is fail-closed.
- the base-ref guard, `PREFIX`, `BASE_REF`, the grace window, the schedule
  and the `is-ancestor` prohibition are all untouched.
- the whole deletion list is printed to the run log before the first delete.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk

* test(reaper): pin the deletion hand-off and fence the delete job structurally

The contract harness drives the `sweep` classifier and can say nothing about
the job that deletes — deletion deliberately lives outside the extracted
script, so what the harness judges stays a classification rather than an
action. Two additions close that gap.

1. The hand-off. `sweep` now publishes `reapable_branches`, the
   machine-readable half of the list it prints, and `reap` consumes that and
   nothing else. Scenarios G1/G2/R1 pin that the list EQUALS the reapable
   bucket — same members, same order — over a population carrying one branch
   in every bucket, and mutations M13/M14 drive both directions red (held
   branches leaking in; the list not published at all).

2. The fence. `reapFenceFailures()` parses the shipped YAML and asserts the
   delete job's structure: its `if:` excludes `pull_request` and gates
   `workflow_dispatch` on `inputs.dry_run == false`; it declares
   `contents: write` and is the ONLY job in the file that does; the top-level
   grant stays `contents: read`; it still `needs: sweep`. New self-test
   battery 6 drives six mutations of the workflow text to red, each asserting
   its anchor was present first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk

* ci(reaper): put every excluded bucket on the run-log audit line

The maintainer's ruling names the run log as the audit trail, and the notice
line named three of the seven buckets — reapable, mergedElsewhere, noPr. The
other four (open, closedUnmerged, grace, protectedBranch) lived only in the
step summary and the uploaded artifact, so the log alone could not answer
"what did it hold back, and why".

Also retires two strings that stopped being true when the reaper was armed:
the summary heading said "DRY RUN. Nothing was deleted." of a run that may
now delete in a later job, and the notice said "Nothing was deleted" of the
whole run rather than of this job. Both now speak for the `sweep` job only,
which is the thing they were ever really asserting — its token grant is
`contents: read` and that has not changed.

No classification changed: the buckets, the guard, the grace window and the
step outputs are byte-identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 9, 2026
…aude/ branches (objectstack-ai#15224)

* ci(reaper): arm the merged-branch reaper for scheduled deletion of claude/ branches

Flips .github/workflows/merged-branch-reaper.yml from report-only to the
scheduled weekly deletion the maintainer ruled on 2026-09-04 (issue objectstack-ai#12771,
decision batch objectstack-ai#30), reaffirming the 2026-08-31 ruling under the base-ref
guard PR objectstack-ai#15144 landed.

Deletion is a SEPARATE job (`reap`), because `permissions:` is scoped per
job. `sweep` keeps `contents: read` + `pull-requests: read` and remains
structurally incapable of deleting a ref; `reap` holds the only
`contents: write` in the file, consumes the `reapable` list `sweep`
publishes as a job output, and computes no classification of its own.

Fences:
- `reap` never runs on `pull_request` — the self-exercising run stays a dry
  run — and its `if:` is an allowlist of `schedule` plus a
  `workflow_dispatch` on which the operator explicitly set `dry_run: false`.
- the new `dry_run` workflow_dispatch input defaults to true, so the manual
  path is fail-closed.
- the base-ref guard, `PREFIX`, `BASE_REF`, the grace window, the schedule
  and the `is-ancestor` prohibition are all untouched.
- the whole deletion list is printed to the run log before the first delete.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk

* test(reaper): pin the deletion hand-off and fence the delete job structurally

The contract harness drives the `sweep` classifier and can say nothing about
the job that deletes — deletion deliberately lives outside the extracted
script, so what the harness judges stays a classification rather than an
action. Two additions close that gap.

1. The hand-off. `sweep` now publishes `reapable_branches`, the
   machine-readable half of the list it prints, and `reap` consumes that and
   nothing else. Scenarios G1/G2/R1 pin that the list EQUALS the reapable
   bucket — same members, same order — over a population carrying one branch
   in every bucket, and mutations M13/M14 drive both directions red (held
   branches leaking in; the list not published at all).

2. The fence. `reapFenceFailures()` parses the shipped YAML and asserts the
   delete job's structure: its `if:` excludes `pull_request` and gates
   `workflow_dispatch` on `inputs.dry_run == false`; it declares
   `contents: write` and is the ONLY job in the file that does; the top-level
   grant stays `contents: read`; it still `needs: sweep`. New self-test
   battery 6 drives six mutations of the workflow text to red, each asserting
   its anchor was present first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk

* ci(reaper): put every excluded bucket on the run-log audit line

The maintainer's ruling names the run log as the audit trail, and the notice
line named three of the seven buckets — reapable, mergedElsewhere, noPr. The
other four (open, closedUnmerged, grace, protectedBranch) lived only in the
step summary and the uploaded artifact, so the log alone could not answer
"what did it hold back, and why".

Also retires two strings that stopped being true when the reaper was armed:
the summary heading said "DRY RUN. Nothing was deleted." of a run that may
now delete in a later job, and the notice said "Nothing was deleted" of the
whole run rather than of this job. Both now speak for the `sweep` job only,
which is the thing they were ever really asserting — its token grant is
`contents: read` and that has not changed.

No classification changed: the buckets, the guard, the grace window and the
step outputs are byte-identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 9, 2026
…e-delivery rejections (objectstack-ai#15276)

* fix(runtime): type the packages-domain `protocol` service handle so undeclared request keys are compile errors (objectstack-ai#15215)

* wip(runtime): type the packages-domain protocol service handle

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

* wip(runtime): add the packages-domain protocol handle typing pin

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

* chore(changeset): patch note for the packages-domain protocol handle typing

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

* docs(permissions): re-anchor the system-context census rows moved by the typing block

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

* docs(permissions): regenerate the system-context census from the merged tree

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(objectql): publish the record's organization on every DataEvent (objectstack-ai#15220)

* fix(objectql): publish the record's organization on every DataEvent (objectstack-ai#14970)

`DataEventSchema.organizationId` was declared and published by the spec half
but populated by nothing, so every `data.record.*` event went out with the key
absent — which the contract requires a consumer to read as "this record is
behind no organization wall". `publishDataEvent` now resolves it from the row
itself: the written record on `created`, the post-state on `updated`, and the
by-id branch's already-read pre-image on `deleted`, so no per-event read is
bought.

The record's organization, never `ExecutionContext.tenantId` — that is the
caller's active org, and the two diverge on exactly the system/unscoped write
this key most needs to label correctly. Absence keeps one spelling: the key is
omitted, never `''` (which the schema refuses outright, dropping the whole
event) and never an explicit `undefined` (which survives `parse` as a present
key).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ

* docs(permissions): re-anchor the system-context census after the engine line shift

Mechanical repair by `node scripts/check-system-context-census.mjs --fix`, the
only correct writer for this table. Pure line rot: the `eventOrganizationId`
helper and its threading shifted every later line in `packages/objectql/src/engine.ts`,
so 14 anchors (15 citation sites — one source line is cited twice) pointed at
the wrong lines.

No population and no classification change: still 106 elevation read sites in
20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared
non-read — the same figures as before the shift. `--fix` did not refuse, and
the diff is digits and nothing else (12 lines added, 12 removed, identical once
digits are stripped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ

---------

Co-authored-by: Claude <noreply@anthropic.com>

* ci(reaper): arm the merged-branch reaper for scheduled deletion of claude/ branches (objectstack-ai#15224)

* ci(reaper): arm the merged-branch reaper for scheduled deletion of claude/ branches

Flips .github/workflows/merged-branch-reaper.yml from report-only to the
scheduled weekly deletion the maintainer ruled on 2026-09-04 (issue objectstack-ai#12771,
decision batch objectstack-ai#30), reaffirming the 2026-08-31 ruling under the base-ref
guard PR objectstack-ai#15144 landed.

Deletion is a SEPARATE job (`reap`), because `permissions:` is scoped per
job. `sweep` keeps `contents: read` + `pull-requests: read` and remains
structurally incapable of deleting a ref; `reap` holds the only
`contents: write` in the file, consumes the `reapable` list `sweep`
publishes as a job output, and computes no classification of its own.

Fences:
- `reap` never runs on `pull_request` — the self-exercising run stays a dry
  run — and its `if:` is an allowlist of `schedule` plus a
  `workflow_dispatch` on which the operator explicitly set `dry_run: false`.
- the new `dry_run` workflow_dispatch input defaults to true, so the manual
  path is fail-closed.
- the base-ref guard, `PREFIX`, `BASE_REF`, the grace window, the schedule
  and the `is-ancestor` prohibition are all untouched.
- the whole deletion list is printed to the run log before the first delete.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk

* test(reaper): pin the deletion hand-off and fence the delete job structurally

The contract harness drives the `sweep` classifier and can say nothing about
the job that deletes — deletion deliberately lives outside the extracted
script, so what the harness judges stays a classification rather than an
action. Two additions close that gap.

1. The hand-off. `sweep` now publishes `reapable_branches`, the
   machine-readable half of the list it prints, and `reap` consumes that and
   nothing else. Scenarios G1/G2/R1 pin that the list EQUALS the reapable
   bucket — same members, same order — over a population carrying one branch
   in every bucket, and mutations M13/M14 drive both directions red (held
   branches leaking in; the list not published at all).

2. The fence. `reapFenceFailures()` parses the shipped YAML and asserts the
   delete job's structure: its `if:` excludes `pull_request` and gates
   `workflow_dispatch` on `inputs.dry_run == false`; it declares
   `contents: write` and is the ONLY job in the file that does; the top-level
   grant stays `contents: read`; it still `needs: sweep`. New self-test
   battery 6 drives six mutations of the workflow text to red, each asserting
   its anchor was present first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk

* ci(reaper): put every excluded bucket on the run-log audit line

The maintainer's ruling names the run log as the audit trail, and the notice
line named three of the seven buckets — reapable, mergedElsewhere, noPr. The
other four (open, closedUnmerged, grace, protectedBranch) lived only in the
step summary and the uploaded artifact, so the log alone could not answer
"what did it hold back, and why".

Also retires two strings that stopped being true when the reaper was armed:
the summary heading said "DRY RUN. Nothing was deleted." of a run that may
now delete in a later job, and the notice said "Nothing was deleted" of the
whole run rather than of this job. Both now speak for the `sweep` job only,
which is the thing they were ever really asserting — its token grant is
`contents: read` and that has not changed.

No classification changed: the buckets, the guard, the grace window and the
step outputs are byte-identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(service-automation): compile the test layer with tsc, and repair the TS2341 x3 it hid (objectstack-ai#15152)

* wip: onboard service-automation typecheck, fix TS2341 residue

* wip: onboarding gate registry entry + changeset

* fix(scripts): re-measure this entry's provenance totals on the merged tree

The `service-knowledge` onboarding landed on `main` between this entry's first
reading and this merge, so every absolute in its provenance block (programs,
pairs, packages, clean count) was a number about a tree that no longer exists.
Re-taken with `--list` on the merge commit itself, all four rows plus the
before/after pair, by varying only what the `typecheck` script names:

  no `typecheck` script  absent   120 programs / 293 pairs
  names tsconfig.json    absent   120 programs / 293 pairs
  names tsconfig.test    PRESENT  121 programs / 302 pairs
  names both (the card)  PRESENT  121 programs / 302 pairs

  before  59 of 78 packages, 120 programs, 293 pairs, 19 clean
  after   60 of 78 packages, 121 programs, 302 pairs, 18 clean

The deltas this block actually claims (+1 package, +1 program, +9 pairs, one
per dep) are unchanged; only the absolutes moved, and the block now says which
merge moved them. The sibling entries' own blocks keep their own historical
readings untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y

---------

Co-authored-by: Claude <noreply@anthropic.com>

* docs(platform-objects): widen sys_email.error description to cover pre-delivery rejections

`sys_email.error` was declared as "Transport error message when status=failed",
but since objectstack-ai#14371 EmailService.recordRejectedMessage also writes status=failed
rows for messages rejected by normalizeMessage before they reach a transport
(prefixed "rejected before delivery: ..."). The declared field help was
narrower than what the column actually holds.

Widen the description (wording settled in triage, issue comment 5504375428)
and regenerate the platform-objects i18n bundle with its own tooling
(node scripts/check-i18n-bundles.mjs --write) rather than hand-editing the
generated file.


Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ

* chore(platform-objects): add changeset for sys_email.error description widening


Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ

---------

Co-authored-by: Litant Ying <litant.dev@proton.me>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: os-sales <sales@objectstack.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants