instar 1.3.585 → 1.3.587

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +85 -32
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/core/IntelligenceRouter.d.ts +10 -0
  5. package/dist/core/IntelligenceRouter.d.ts.map +1 -1
  6. package/dist/core/IntelligenceRouter.js +37 -3
  7. package/dist/core/IntelligenceRouter.js.map +1 -1
  8. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  9. package/dist/core/PostUpdateMigrator.js +17 -0
  10. package/dist/core/PostUpdateMigrator.js.map +1 -1
  11. package/dist/core/internalFrameworkDefault.d.ts +50 -0
  12. package/dist/core/internalFrameworkDefault.d.ts.map +1 -0
  13. package/dist/core/internalFrameworkDefault.js +72 -0
  14. package/dist/core/internalFrameworkDefault.js.map +1 -0
  15. package/dist/core/types.d.ts +18 -0
  16. package/dist/core/types.d.ts.map +1 -1
  17. package/dist/core/types.js.map +1 -1
  18. package/dist/scaffold/templates.js +4 -4
  19. package/dist/server/routes.d.ts.map +1 -1
  20. package/dist/server/routes.js +41 -3
  21. package/dist/server/routes.js.map +1 -1
  22. package/dist/server/stopGate.d.ts +37 -0
  23. package/dist/server/stopGate.d.ts.map +1 -1
  24. package/dist/server/stopGate.js +85 -8
  25. package/dist/server/stopGate.js.map +1 -1
  26. package/package.json +1 -1
  27. package/scripts/safe-merge.mjs +68 -5
  28. package/src/data/builtin-manifest.json +63 -63
  29. package/src/scaffold/templates.ts +4 -4
  30. package/upgrades/1.3.586.md +76 -0
  31. package/upgrades/1.3.587.md +28 -0
  32. package/upgrades/side-effects/autonomous-run-registration-guarantee.md +40 -0
  33. package/upgrades/side-effects/provider-fallback-default-policy.md +52 -0
  34. package/upgrades/side-effects/safe-merge-native-auto-merge.md +92 -0
@@ -0,0 +1,28 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ Instar's internal background LLM work — sentinels, gates, and reflectors — now runs **off Claude by default** when the agent has another provider CLI installed. The agent picks the first available provider in the preference order **Codex → PI → Gemini → Claude**, with the existing failure-swap engine falling down the chain and Claude as the true last resort. A new bounded per-attempt timeout (`intelligence.swapAttemptTimeoutMs`, default 5s) keeps the longer chain from stacking slow providers into the very stall it exists to prevent — a timed-out provider is abandoned and the next one is tried.
9
+
10
+ The `job` category is deliberately **excluded** (cost-bearing background jobs stay on the agent default). Operators retain full control: an explicit `sessions.componentFrameworks` block wins verbatim, and setting it to `{}` reverts to exactly the prior behavior.
11
+
12
+ ## What to Tell Your User
13
+
14
+ If your agent has Codex (or another non-Claude CLI) installed, its background safety checks now run off Claude by default — so a Claude outage or a maxed weekly quota can no longer freeze your sentinels, gates, or message delivery. You can see which provider is serving each check in the routing and metrics views, and you can override the routing or revert it entirely any time with one config setting. If you only run Claude, nothing changes — by design.
15
+
16
+ ## Summary of New Capabilities
17
+
18
+ - Internal sentinels, gates, and reflectors auto-route off Claude via an active-filtered fallback chain (Codex then PI then Gemini then Claude), so no single provider's bad night can strangle the agent.
19
+ - A tunable per-attempt swap timeout keeps the fallback chain fast.
20
+ - A new degrade signal makes a slow provider being abandoned visible in the existing observability surfaces.
21
+
22
+ ## Evidence
23
+
24
+ - `src/core/internalFrameworkDefault.ts` (new) — the active-filtered policy resolver + the `INTERNAL_FRAMEWORK_PREFERENCE` constant.
25
+ - `src/core/IntelligenceRouter.ts` — `Promise.race` per-attempt swap timeout + `swap-attempt-timeout` degrade signal; fail-open, fail-closed-if-all-down preserved.
26
+ - `src/commands/server.ts` — boot active-set probe + the live-read, layered `resolveConfig` (preserves CartographerSweep's runtime override).
27
+ - 31 new tests (3 unit files 24 green + integration + e2e/wiring-integrity); tsc clean; 14 lints clean; second-pass review concurred.
28
+ - Spec `docs/specs/provider-fallback-default-policy.md` — CONVERGED over 4 review rounds (6 internal lenses + codex/gemini external + conformance gate each).
@@ -0,0 +1,40 @@
1
+ # Side-Effects Review — Autonomous-Run Registration Guarantee (GAP-B, PR1)
2
+
3
+ **Spec:** docs/specs/autonomous-run-registration-guarantee.md (converged R1 + approved — operator full pre-approval, Justin 2026-06-15). **Parent:** An Autonomous Run Must Outlive Its Session.
4
+ **Scope:** PR1 only — the deterministic read-path correction (D1 precedence chain + D2 server-side topic resolution). Ships LIVE (no flag; a pure accuracy fix to an existing read).
5
+ **Files:** src/server/stopGate.ts, src/server/routes.ts, tests/unit/stopGate.test.ts, tests/integration/stop-gate-autonomous-topic-resolution.test.ts
6
+
7
+ ## What changed
8
+
9
+ 1. **stopGate.ts (`readAutonomousActive`):** replaced the single oldest-legacy-path existence check with a fixed precedence chain — (1) the per-topic canonical path .instar/autonomous/TOPIC.local.md, only when a topicId resolves; (2) the .instar legacy single-file; (3) the .claude oldest-legacy single-file. autonomousActive is true if ANY exists. The historical autonomousStateFile override still wins as the sole path read (back-compat). New optional HotPathInputs fields: topicId and stateRoot.
10
+ 2. **stopGate.ts (`resolveTopicForTmux`, new exported pure fn):** inverts topic-session-registry.json's topicToSession map on a tmux name — the SAME inversion the bash stop hook uses. Pure, injectable reader, fail-open to null on any read/parse error.
11
+ 3. **routes.ts (`resolveTopicForStopGate` helper + two getHotPathState callsites):** resolves the Claude session UUID to its tmux name via the session manager's claudeSessionId record, then to a topicId via resolveTopicForTmux, and passes topicId + stateRoot into both hot-path reads.
12
+
13
+ ## Blast radius
14
+
15
+ Server-only. The single behavior touched is the stop-gate's autonomousActive read — which it computes for two consumers: the hot-path stop decision input and the StopNotifier unattended-session gate. No agent-installed file changed; no new HTTP route, no new config key, no new state file, no schema change. A single-machine or non-autonomous agent simply reads the same three paths and gets the same answer it would expect.
16
+
17
+ ## Reversibility
18
+
19
+ Fully reversible. This is a read-path change with no persisted side effects — nothing is written, migrated, or stamped. Reverting the two source files restores the prior single-path read exactly; there is no on-disk state to unwind.
20
+
21
+ ## Signal vs authority
22
+
23
+ The stop-gate's autonomousActive is a SIGNAL that informs the stop decision and the unattended-notice gate — it is not itself an authority that blocks or commits anything. This change adds NO new authority: it makes the existing signal MORE accurate by reading the canonical per-topic path a modern autonomous run actually writes. It never blocks a message, never gates a session, never spawns or kills anything. A more-accurate "this is an autonomous run" reading only changes whether the existing revive/notify machinery engages — the machinery's own authorities are unchanged.
24
+
25
+ ## Failure modes
26
+
27
+ The one boundary that matters is the topic-resolution miss (no session record, corrupt/missing registry, unknown tmux name). It resolves to undefined/null and the read falls back EXPLICITLY to both legacy paths — it can never coerce a miss into a silent autonomousActive:false (the no-silent-fallbacks ratchet). Three @silent-fallback-ok catches are annotated and justified: a session-manager read failure (resolver returns undefined → legacy fallback), a per-path stat error (the chain continues to the next path, never short-circuiting the whole read), and a corrupt/missing registry (resolveTopicForTmux returns null → legacy fallback). All three are test-covered on both sides of the boundary.
28
+
29
+ ## Migration parity
30
+
31
+ NONE required. No agent-installed file changed — this is server code only (the stop-gate read and the route resolver). Existing agents pick up the corrected read the moment their server runs the new code; there is no .claude/settings.json hook, .instar/config.json default, CLAUDE.md section, hook script, or built-in skill to migrate.
32
+
33
+ ## Tests
34
+
35
+ - tests/unit/stopGate.test.ts — D1 precedence (per-topic wins; a different topic does not activate; fall-through to each legacy path; none-exist false; topic-less still reads both legacy paths; override-is-sole-path back-compat) and the D2 unresolved-topic boundary both sides (HIT reads per-topic; MISS-with-legacy stays true; MISS-with-nothing is the genuinely-inactive false) plus resolveTopicForTmux inversion (hit, unknown-name miss, null/empty, corrupt-registry fail-open).
36
+ - tests/integration/stop-gate-autonomous-topic-resolution.test.ts — the full HTTP hot-path: UUID→tmux→topic resolution feeds the canonical per-topic read end to end.
37
+
38
+ ## What's scoped to the follow-on PR
39
+
40
+ PR1 covers the read-path correction only — the gate now SEES a registration that already exists on disk. The dev-gated registration-guard that auto-writes a TTL-bounded provenance stub when a run starts without one (D3) is specified separately in the spec's PR2 section, with its own dev-gate rollout and sign-off. <!-- tracked: autonomous-run-registration-guarantee -->
@@ -0,0 +1,52 @@
1
+ # Side-Effects Review — Provider-Fallback Default Policy
2
+
3
+ **Spec:** `docs/specs/provider-fallback-default-policy.md` (CONVERGED 4 rounds, approved:true) · CMT-1554/1555
4
+ **Change:** ship the existing `IntelligenceRouter` failure-swap engine turned ON by default for internal gating/sentinel/reflector components (active-filtered chain Codex→PI→Gemini→Claude), plus a bounded per-attempt swap timeout so the longer default chain cannot re-create the stall it prevents.
5
+
6
+ ## Phase 1 — Principle check (signal vs authority)
7
+ This touches a decision point (which provider serves a gating/safety call), so the principle applies. The change adds **no new brittle blocking authority**: the router's `failureSwap` fail-closed authority already exists and is preserved (it re-throws when primary + every swap target is down → the gating caller fails closed, never a silent brittle heuristic). The new pieces are (a) a default *preference order* (a policy, not an authority) and (b) a **fail-open** per-attempt timeout (timed-out attempt → next target → Claude tail → fail-closed only if all exhausted). The timeout is signal-shaped (abandon-slow → try-next) and cannot turn a fail-closed outcome into a silent pass. Observability rides the existing `DegradationReporter` (`onDegrade` reason `swap-attempt-timeout:<target>`). **Compliant.**
8
+
9
+ ## Phase 2 — Plan
10
+ Built in the existing worktree `.worktrees/provider-fallback-chain` (verified current with `JKHeadley/main @ce13f42a3`; `git remote -v` → JKHeadley; tags `review-convergence`+`approved:true` present). Decision points touched: the router's `failureSwap` (existing authority — preserved). Existing detectors interacted with: `DegradationReporter` (observability), the per-framework circuit breakers (unchanged). Rollback: the no-op guarantee + the `{}` config lever (below).
11
+
12
+ ## 1. Over-block
13
+ The change does not *block* inputs; it routes them to a different provider. The closest "over-block" risk is the §4.5 per-attempt timeout abandoning a provider that *would* have answered correctly just slowly. Mitigated: (a) the cap (5s) only fires on a swap attempt that is already past the failed primary, i.e. in a degraded path; (b) a timed-out attempt is *fail-open* — it advances to the next target and ultimately the Claude tail, never to a denial; (c) the cap is operator-tunable (`intelligence.swapAttemptTimeoutMs`). No legitimate gating decision is rejected — at worst it is served by the next provider or Claude.
14
+
15
+ ## 2. Under-block
16
+ A provider that is reachable but returns **well-formed-but-semantically-wrong** output is not caught (the circuit breaker only trips on errors). This is a **pre-existing property of any LLM gate**, provider-independent, NOT introduced here — and round-3 grounding confirmed the gating *callers* already validate their own output (`MessagingToneGate.parseResponse` fail-opens on malformed JSON + validates the `VALID_RULES` allowlist; `MessageSentinel` try/catch fail-opens). This change only changes *which* provider can serve a weak answer, not the property. Documented (§6.4) as a tracked, non-owed hardening item, not part of this feature's scope.
17
+
18
+ ## 3. Level-of-abstraction fit
19
+ Correct layer. The fallback mechanism, breakers, and gating-scope already live in `IntelligenceRouter` (the right place — one router every internal LLM call already flows through). This change is a thin **policy** computed at the router-construction site (`server.ts`) + one minimal engine touch (the swap-loop timeout). It does not duplicate a smarter gate; it *feeds* the existing `DegradationReporter`/`/metrics/features` observability rather than building a parallel one. The `job` category was deliberately EXCLUDED so a cost-bearing background feature (CartographerSweep) is never auto-armed as a side effect of this policy.
20
+
21
+ ## 4. Signal vs authority compliance
22
+ See Phase 1. No brittle check gains blocking authority. The timeout is fail-open; the only blocking authority (router re-throw → caller fail-closed) is the pre-existing one, unchanged. **Compliant** (`docs/signal-vs-authority.md`).
23
+
24
+ ## 5. Interactions
25
+ - **CartographerSweep** mutates `config.sessions.componentFrameworks` in memory at runtime. The §4.6 resolver reads config **live every call** and layers the computed default UNDER any live override, so CartographerSweep's injection still wins for its slot. A frozen memoized default would have silently disabled the freshness sweep — this was caught in convergence (R3-1) and is the reason the resolver is live-read + layered, with only the active-SET memoized.
26
+ - **Operator-set detection** is snapshotted once at the construction site (which runs before CartographerSweep's auto-vivify — verified ~line 4755 vs ~11331), so a later in-memory auto-vivify cannot masquerade as an operator override and disable the default (M5; covered by a unit test).
27
+ - **Per-framework circuit breakers** (existing): a broadly rate-limited Codex trips its own breaker → gating calls skip it fast, damping any herd onto the Claude tail (§6.2). Non-gating calls are untouched (they propagate to their heuristic — no herd).
28
+ - No double-fire / shadowing: the swap loop is unchanged except for wrapping each attempt in `Promise.race`; the re-throw-if-all-down path is preserved.
29
+
30
+ ## 6. External surfaces
31
+ - `GET /intelligence/routing` and `/metrics/features` now reflect sentinels/gates/reflectors resolving off Claude on agents that have an off-Claude CLI — visible, intended observability (the operator can SEE the routing). No new route added (config-driven).
32
+ - CLAUDE.md template gains the corrected default-behavior text (new agents via `generateClaudeMd`; existing agents via `migrateClaudeMd` appended on the new `run off Claude by default` marker).
33
+ - Depends on runtime condition: which provider CLIs are installed on the machine (active-set). Honestly machine-local (see §7). No timing/conversation-state coupling.
34
+
35
+ ## 7. Multi-machine posture
36
+ **Machine-local BY DESIGN.** The active-set is computed from `buildProvider(fw)!==null` against THIS machine's installed CLIs, at THIS machine's router-construction site, and is **never persisted or replicated** (runtime-computed default, no config block written). So machine A's installed-providers can never pin onto machine B — each machine routes by what it has. `/intelligence/routing` reflects the local machine's resolution (the route reads the local `resolveConfig`). This is the correct posture (a different machine genuinely has different CLIs) and is stated explicitly, not a silent single-machine assumption. No user-facing notice surface (no one-voice concern); no durable state that strands on topic transfer; no generated URL.
37
+
38
+ ## 8. Rollback cost
39
+ Cheap and layered. (a) **No-op guarantee:** an agent with no off-Claude provider resolves to byte-identical-today behavior (claude-only → empty swap). (b) **`{}` lever:** operator sets `sessions.componentFrameworks: {}` → every category resolves to the agent default, empty swap — exactly today's behavior (unit-tested, M7). (c) **Timeout knob:** `intelligence.swapAttemptTimeoutMs` tunes/effectively-disables the cap (set very large). (d) **Full back-out:** revert the PR — purely additive code, no data migration, no agent-state repair (the default is runtime-computed, nothing persisted to roll back).
40
+
41
+ ## No-deferrals (Phase 4.5)
42
+ The one out-of-scope item (semantically-wrong swap-target output, §6.4) is a pre-existing provider-independent property, explicitly NOT this feature's in-scope work, and carries an in-spec `<!-- tracked: -->` marker for No-Deferrals hygiene. No partial fix is shipped.
43
+
44
+ ## Phase 5 — Second-pass review
45
+ **Concur with the review.** An independent reviewer audited the change against the real code (not the artifact's claims) and confirmed each high-risk dimension at named file:line:
46
+ - Fail-open / Signal-vs-Authority: `IntelligenceRouter.ts:212-268` — the cap only races already-degraded swap attempts (`continue` → next → Claude tail); the pre-existing fail-closed re-throw (`throw err`, line 268) and non-gating passthrough (line 206) are unchanged. No new blocking authority.
47
+ - `Promise.race` orphan-safety: `IntelligenceRouter.ts:228-238` uses `await Promise.race([...])` (never a detached handle); the unit test registers a real `unhandledRejection` listener, rejects the abandoned attempt after the cap, and asserts zero unhandled + late-result-not-used.
48
+ - §4.6 live-read+layer + boot-snapshot ordering: `server.ts:4750-4751` snapshots operator-set at construction; `resolveConfig` (4766-4781) reads live + layers computed default UNDER live overrides; CartographerSweep's auto-vivify (`server.ts:11329-11331`) runs later and cannot masquerade as operator-set — its `overrides` survive the layering.
49
+ - M11 honest+sufficient: the reviewer verified the REAL `ExternalOperationGate.consultLLM` (`ExternalOperationGate.ts:509-540`) fails closed (`'show-plan'` on catch) exactly as the test's synthetic fail-closed caller models; `MessagingToneGate` fails OPEN by design (delivery-path), correctly asserted as such.
50
+ - No double-fire/shadow/multi-machine strand: `target===framework` guard prevents re-firing the primary; active-set is machine-local and never persisted; `job` excluded so CartographerSweep's slot is never contended.
51
+
52
+ Non-blocking observation (already documented in spec §4.6): a runtime `PATCH /config {sessions:{componentFrameworks}}` changes gating routing live (resolveConfig reads live) — operator-scoped + every path fail-opens, so safe.
@@ -0,0 +1,92 @@
1
+ # Side-effects review — safe-merge `--auto` (native auto-merge path)
2
+
3
+ **Change:** add a `--auto` flag to `scripts/safe-merge.mjs` that arms GitHub
4
+ native auto-merge and returns immediately, as an additive alternative to the
5
+ existing `--admin` synchronous poll-and-merge path. Plus unit coverage.
6
+
7
+ ## 1. Over-block (legitimate inputs rejected that shouldn't be)
8
+ None introduced. The new path is opt-in (`--auto`). The one new rejection is the
9
+ incoherent `--auto --admin` combo — that SHOULD be rejected (the two are
10
+ contradictory merge strategies). No existing invocation is affected: every
11
+ current caller passes `--admin` (or neither) and behaves byte-identically.
12
+
13
+ ## 2. Under-block (failure modes still missed)
14
+ The `--auto` path delegates required-check enforcement to GitHub's branch
15
+ protection. If a repo has auto-merge enabled but branch protection is weak/absent
16
+ (no required checks), native auto-merge would merge on mergeable-state alone — but
17
+ that is GitHub's configured posture, not a bypass this script introduces, and it
18
+ is identical to what a human `gh pr merge --auto` would do. The `--admin` path's
19
+ manual re-imposition (e2e-presence, producer-bound floor) intentionally does NOT
20
+ run on `--auto` because GitHub enforces the required contexts itself; on a repo
21
+ with no required contexts there is nothing to enforce either way. Documented in
22
+ the ELI16. Mitigation for instar's own repo: main has 12 required checks, so
23
+ auto-merge waits for all of them.
24
+
25
+ ## 3. Level-of-abstraction fit
26
+ Correct layer: this is the merge-mechanism wrapper, and native auto-merge is a
27
+ merge mechanism. It does not duplicate a higher gate — it DELEGATES to GitHub's
28
+ branch protection (a smarter, platform-level gate) rather than re-implementing
29
+ enforcement in-process. That is the right direction (less brittle in-process
30
+ logic, more native enforcement).
31
+
32
+ ## 4. Signal vs authority compliance
33
+ The act-time authority on the `--auto` path is GitHub's branch protection (it
34
+ decides whether/when to merge). `safe-merge --auto` is a thin arming command +
35
+ honest confirmation reader — it holds no brittle blocking authority of its own;
36
+ it cannot merge anything GitHub's required checks haven't cleared. This is MORE
37
+ aligned with signal-vs-authority than `--admin` (which bypasses the authority and
38
+ then re-imposes it in brittle script logic). No new in-process gate is created.
39
+
40
+ ## 5. Interactions (shadowing, double-fire, races)
41
+ - Does not shadow or get shadowed by the `--admin` path — they are mutually
42
+ exclusive (parser-enforced) and the `--auto` branch returns before the poll
43
+ loop is reached.
44
+ - No double-merge: arming auto-merge is idempotent (re-arming an armed PR is a
45
+ no-op on GitHub); the independent confirmation reports `merged`/`armed`/error
46
+ honestly.
47
+ - The dangerous-command-guard does NOT block `gh pr merge --auto` (verified
48
+ live 2026-06-15: `--auto` only QUEUES, it does not merge-before-green) — so the
49
+ arming command runs without tripping the #539-outage guard.
50
+ - `--match-head-commit` interaction: when an explicit SHA is supplied it is
51
+ passed through to `gh pr merge --auto`, so a moved head cancels the arming
52
+ (GitHub behavior), consistent with the head-pin intent. When NO explicit SHA is
53
+ supplied, the head is intentionally NOT pinned on the `--auto` path — native
54
+ auto-merge re-evaluates required checks against whatever the head becomes, so a
55
+ post-arming push simply makes GitHub wait for the new head's checks; it cannot
56
+ merge an unverified head. (This differs from the `--admin` path, which always
57
+ pins; the difference is sound under native enforcement.)
58
+
59
+ ## 6. External surfaces
60
+ - Changes the script's CLI contract (new `--auto` flag, new feature
61
+ `native-auto-merge`, new exit code `5 = autoMergeArmed`) — surfaced in
62
+ `--capabilities` and asserted in tests, so a caller probing the contract sees
63
+ it.
64
+ - Depends on the repo having "Allow auto-merge" enabled (enabled on
65
+ JKHeadley/instar 2026-06-15). On a repo without it, arming fails with a clear
66
+ `refused:auto-arm-<cls>` + a message naming the missing setting — an honest
67
+ refusal, never a silent degrade, and the `--admin` path remains the fallback.
68
+
69
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
70
+ **Machine-local BY DESIGN.** `safe-merge.mjs` is a stateless CLI invoked
71
+ synchronously by whichever machine runs it; it holds no durable state, opens no
72
+ topic, generates no URL. The arming call targets GitHub (a shared external
73
+ authority), so the outcome is identical regardless of which machine arms it —
74
+ and GitHub de-dupes (arming an already-armed PR is a no-op), so two machines
75
+ arming the same PR cannot double-merge. No replication or proxied-read needed.
76
+
77
+ ## 8. Rollback cost
78
+ Trivial. The flag is additive; reverting the commit removes `--auto` and leaves
79
+ the untouched `--admin` path. No data migration, no state repair, no fleet
80
+ coordination. At the repo level, `allow_auto_merge` can be toggled back off in
81
+ one API call / settings click if ever undesired (independent of this code).
82
+
83
+ ## Second-pass review (required — touches merge machinery)
84
+
85
+ Second-pass: CONCUR — the `if (args.auto)` block early-returns before the poll
86
+ loop so the `--admin` path is behavior-identical, the independent `pr view`
87
+ re-read only claims `merged`/`armed` on a real `MERGED`/`autoMergeRequest` state
88
+ and otherwise exits 2 (no false success), and mutual-exclusion + exit codes are
89
+ correct; the only weakening is the author-disclosed branch-protection delegation
90
+ (§2), which is bounded — it adds no bypass and is moot on JKHeadley/instar's 12
91
+ required checks, so "strictly safer than --admin" holds wherever required checks
92
+ exist. (Reviewer's §5 doc-precision nit on head-pinning was applied above.)