Skip to content

Commit 6707537

Browse files
committed
Merge origin/main into fix/386-published-tag-immutability
Two conflicts, both from concurrent additions at the same anchor: - .github/workflows/ci.yml: #465 added a branch-protection job and #468 a tag-immutability job at the same insertion point. Both are kept, and tag-immutability is registered in both places a job must appear: the ci-summary needs list and the required_results expression. - plugins/ca-pi/CHANGELOG.md: this branch's section is renumbered 0.1.19 on top of main's 0.1.18 (the /ca-doctor MCP reporting from #467), which the first resolution dropped. The chain now reads 0.1.19 -> 0.1.18 -> 0.1.17 without a gap, and plugins/ca-pi/package.json plus the root descriptor advance to 0.1.19 with it. Gates: sync-core --check OK (48 files x 3 plugins), build-surface --check OK, build-host-packages --check OK (0.1.18 -> 0.1.19 against origin/main).
2 parents cde209c + 8b5af21 commit 6707537

46 files changed

Lines changed: 2921 additions & 95 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.codearbiter/decisions/0015-all-live-git-enforcers-and-persistent-trusted-identity.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ status: accepted
33
date: 2026-07-16
44
title: Require every live git enforcer and persist trusted executable identity
55
decided-by: SUaDtL@users.noreply.github.com
6-
supersedes: 0014
6+
supersedes: 0014-githook-shim-dropin-fail-closed
77
governs: core/pysrc/_githooks.py, core/pysrc/session-start.py, plugins/*/hooks/_githooks.py, plugins/*/hooks/session-start.py
88
---
99

‎.codearbiter/decisions/0016-bounded-pi-child-credential-projection.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ status: accepted
33
date: 2026-07-22
44
title: Permit bounded selected-provider credential projection for isolated Pi children
55
decided-by: SUaDtL@users.noreply.github.com
6-
supersedes: 0014
6+
supersedes: 0014-pi-host-authentication-and-fail-closed-tool-boundary
77
governs: .codearbiter/security-controls.md, .codearbiter/specs/pi-support.md, .codearbiter/plans/pi-support.md, plugins/ca-pi/**
88
---
99

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
status: accepted
3+
date: 2026-07-25
4+
title: Hook input fails open on malformed shape as well as malformed syntax
5+
decided-by: SUaDtL@users.noreply.github.com
6+
supersedes: none
7+
governs: core/pysrc/_hooklib.py, core/pysrc/pre-bash.py, core/pysrc/pre-write.py, core/pysrc/pre-edit.py, core/pysrc/pre-read.py, core/pysrc/post-write-edit.py
8+
---
9+
10+
# ADR-0020 — Hook input fails open on malformed shape as well as malformed syntax
11+
12+
## Status
13+
Accepted — explicitly decided by SUaDtL@users.noreply.github.com on 2026-07-25.
14+
15+
## Context
16+
`_hooklib.read_input()` already carries a documented, deliberate exception to the fail-loud
17+
principle: a malformed stdin payload must NOT brick the session by blocking every subsequent tool
18+
call, so a parse error warns and returns `{}`.
19+
20+
That exception covers malformed **syntax** only. A payload that is valid JSON but not an object —
21+
`[]`, `3`, `"str"`, `true`, `null` — parses cleanly and is returned as a non-dict. Downstream,
22+
`tool_input(data)` evaluates `(data or {}).get(...)`, so *falsy* non-dicts (`[]`, `null`) are
23+
accidentally safe via the `or {}`, while *truthy* ones (`3`, `"str"`, `true`) raise AttributeError
24+
out of the guard.
25+
26+
The decisive fact is that **the hook envelope is host-produced, not model-produced.** It arrives on
27+
stdin from Claude Code, Codex, or Pi. A model can place hostile content *inside* `tool_input`
28+
fields, but cannot make the top-level object a list. A non-dict envelope therefore means host
29+
misbehaviour or version drift — a compatibility event, not an attack.
30+
31+
## Decision
32+
Treat a malformed **shape** exactly as the existing exception treats malformed **syntax**: normalize
33+
any non-dict payload to `{}` at the single `read_input()` chokepoint, and widen the docstring to say
34+
so. Unreadable input is unreadable, whether the failure is syntactic or structural.
35+
36+
The distinction between the two carries no security content here. Both mean *the guard cannot read
37+
this call*, which is the exact situation the documented exception already ruled on.
38+
39+
## Alternatives considered
40+
- **Fail closed on a non-dict envelope** — rejected. It buys no protection against a threat that
41+
cannot reach this surface (the envelope is not model-controlled), while creating precisely the
42+
failure the existing exception exists to prevent: a host payload change would brick every tool
43+
call until codeArbiter shipped a fix.
44+
- **Split the rule by position — router fails closed, guard fails open** — considered and not taken
45+
as doctrine. It is more precise, but it makes every future hook author decide which side their
46+
code is on, and the asymmetry is better recorded as a note than as a rule (see below).
47+
48+
## Consequences
49+
One rule, stated once, at one chokepoint: unreadable input yields `{}` and a warning. Every host —
50+
including future ones — inherits it without a per-host decision.
51+
52+
**A deliberate asymmetry is recorded rather than removed.** The ca-codex *adapter*
53+
(`plugins/ca-codex/hooks/pre-tool-adapter.py`) fails CLOSED on a non-object payload, and that stays.
54+
The adapter is a **router**: it decides which guard to dispatch to, so a payload it cannot route is
55+
one it cannot prove safe. A guard that cannot parse its own input is a different case. That
56+
difference is intentional, not drift, and this ADR is the place it is written down. The adapter's
57+
refusal is additionally dormancy-aware — in a repository that never opted into codeArbiter it passes
58+
through untouched — so failing closed there cannot break unrelated projects.
59+
60+
## Risks
61+
Normalizing a non-dict envelope to `{}` means a guard proceeds with no `tool_input` rather than
62+
refusing, so a host that silently changed its envelope shape would degrade to unenforced calls with
63+
only a warning to show for it. That is the accepted cost of the existing exception, now applied
64+
consistently.
65+
66+
This decision is proven wrong if a host is found where the envelope shape is model-influenceable, or
67+
if the warning proves too quiet to surface a real host drift — in which case the remedy is to make
68+
the degradation louder, not to fail closed and brick the session.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
status: accepted
3+
date: 2026-07-25
4+
title: Per-call Python bridge spawn is the cross-host cost model
5+
decided-by: SUaDtL@users.noreply.github.com
6+
supersedes: none
7+
governs: plugins/ca-pi/tools/src/bridge.ts, plugins/ca-pi/hooks/pi-bridge.py
8+
---
9+
10+
# ADR-0021 — Per-call Python bridge spawn is the cross-host cost model
11+
12+
## Status
13+
Accepted — explicitly decided by SUaDtL@users.noreply.github.com on 2026-07-25.
14+
15+
## Context
16+
Every gated Pi tool call spawns a fresh Python bridge process. Tribunal finding performance-001
17+
raised whether that should become a persistent bridge worker, and downgraded itself from high on a
18+
decisive observation: **Claude Code pays the same per-call hook-spawn cost.** The question is
19+
therefore not whether spawning is free — it is whether Pi should diverge from the cost model every
20+
other host already accepts.
21+
22+
`.github/scripts/pi_benchmark.py` measures the cost (`medianMs`, `p95Ms`) and is wired into CI path
23+
filters, so the number is observable rather than anecdotal.
24+
25+
## Decision
26+
Accept the per-call spawn as the cross-host standard cost model. Do not build a persistent bridge
27+
worker.
28+
29+
The per-call process is **stateless by construction**: it holds no lifetime, carries nothing between
30+
gate decisions, and dies with the decision it made. That property is the point, not a side effect.
31+
32+
## Alternatives considered
33+
- **Persistent bridge worker (daemon)** — rejected. It places a long-lived process holding
34+
enforcement authority in front of every gated tool call, requiring its own authentication, health
35+
checking, restart, and staleness handling. A wedged worker degrades every subsequent gated call
36+
rather than one.
37+
- **Reduce spawn cost instead** — not needed now, and explicitly the preferred remedy if latency
38+
ever becomes a real problem (see reopen condition).
39+
40+
## Consequences
41+
One code path, no daemon lifecycle, and no state that can be poisoned between calls. Pi's cost
42+
profile matches Claude Code's, so "is this slow?" is answered the same way on every host.
43+
44+
The cost is linear in gated tool calls and is paid on every one. That is accepted.
45+
46+
## Risks
47+
The rejection rests on a judgement about where this codebase actually breaks. The 2026-07-25 sweep
48+
produced three independent defects from long-lived or ambient state — a statusline pinned into a
49+
worktree that was then pruned, a broker listener that could outlive its child, and a token bound to a
50+
broker rather than to a process. A daemon in the enforcement path would add exactly that class of
51+
surface.
52+
53+
This decision is proven wrong if the measured `p95Ms` becomes a real impediment to normal work. The
54+
remedy in that case is to make the bridge **cheaper to start** — trimming imports, deferring work
55+
off the hot path — not to keep one alive. Adopting a persistent worker would require superseding this
56+
ADR and confronting the daemon-lifetime surface deliberately, rather than arriving at it by
57+
increments.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
---
2+
status: accepted
3+
date: 2026-07-25
4+
title: Auto-route unambiguous, non-destructive intent into its command
5+
decided-by: SUaDtL@users.noreply.github.com
6+
supersedes: none
7+
governs: core/surface/includes/redirect.md, core/surface/includes/routing-table.md, plugins/ca/ORCHESTRATOR.md, plugins/ca-codex/ORCHESTRATOR.md, plugins/ca-pi/ORCHESTRATOR.md
8+
---
9+
10+
# ADR-0022 — Auto-route unambiguous, non-destructive intent into its command
11+
12+
## Status
13+
Accepted — explicitly decided by SUaDtL@users.noreply.github.com on 2026-07-25. Amends ORCHESTRATOR
14+
§6; the §6 invariant itself is preserved, not relaxed (see Decision).
15+
16+
## Context
17+
§6 read: *"All intent flows through a slash command … The user picks; nothing routes without their
18+
command."* In practice the orchestrator would infer the intent, name the exact command, and then ask
19+
the user to type it back — a redirect of the form *"for that action, run `/ca:fix <exactly what you
20+
just asked for>`"*.
21+
22+
That is ceremony, not governance. Naming the command demonstrates the routing was already
23+
understood; requiring it to be retyped adds friction without adding a decision.
24+
25+
The friction also produced harm rather than safety. Issue #308 records a post-merge cleanup where no
26+
command owned the operation and the redirect loop produced **two incorrect routes — first to
27+
`/ca:chore`, then to `/ca:override`.** An override for routine repository hygiene is exactly what
28+
overrides exist to be rare for: a rule intended to ensure deliberate routing instead manufactured an
29+
unjustified bypass.
30+
31+
## Decision
32+
Route on understood intent, in three tiers:
33+
34+
1. **Unambiguous and non-destructive** — route directly into the command. Every gate runs exactly as
35+
if the user had typed it.
36+
2. **Probable** — ask once, naming the command (*"did you mean `/ca:fix`?"*). One approval, then
37+
route. The user approves rather than retypes.
38+
3. **Genuinely unclear** — present the candidate commands and let the user pick.
39+
40+
**The invariant §6 actually protects is preserved by construction.** §6 exists so that nothing
41+
happens outside a gated command path — not so that the user does typing. Auto-routing *into* the
42+
command satisfies it completely. What remains prohibited, and is the line this ADR draws, is the
43+
orchestrator performing the work itself instead of routing: it routes the command, it does not
44+
improvise the operation.
45+
46+
**Clarity and risk are separate axes.** Tier 1 requires BOTH unambiguous intent AND a
47+
non-destructive command. Anything irreversible or gate-bypassing — `/ca:override`, merge to the
48+
default branch, branch or worktree deletion, release and tag publication, `/ca:dev` entry — drops to
49+
tier 2 and asks, even when the intent is obvious. There the confirmation is the gate, not friction.
50+
51+
## Alternatives considered
52+
- **Keep §6 as written** — rejected. The dead end it produced was caused by no command owning the
53+
operation, and the redirect loop converted that gap into a wrong override rather than preventing
54+
one.
55+
- **Auto-route anything unambiguous, destructive included** — rejected. The commands' own gates do
56+
stop and ask in most cases, but "clear intent" and "safe to perform without confirmation" are
57+
different properties, and conflating them puts the irreversible cases on the wrong side.
58+
- **Declare the destructive set per-command in the routing table** — considered and deferred. It is
59+
more auditable and prevents a new command being auto-routed by omission; recorded here as the
60+
preferred refinement if the hardcoded set proves hard to keep current.
61+
62+
## Consequences
63+
The redirect stops asking for a command it has already named. A user saying "go fix that" or "let's
64+
run a checkpoint" reaches the routed skill with its gates intact.
65+
66+
Issue #308's other half ships alongside: a sanctioned owner for the merged-branch transition, which
67+
proves the branch is an ancestor of the *fetched* default, classifies dirty artifacts as unique,
68+
redundant, or superseded, and never discards unique or ambiguous data without per-item confirmation.
69+
Closing that gap removes the cause the redirect loop was reacting to.
70+
71+
## Risks
72+
"Unambiguous" is a judgement the orchestrator makes about its own routing, so a mis-classification
73+
routes a command the user did not intend. Two things bound it: tier 1 excludes everything
74+
irreversible, and the routed command's own gates still run — a wrongly routed `/ca:fix` stops at
75+
`tdd` Phase 1 exactly as a typed one would.
76+
77+
The destructive set is enumerated in the orchestrator rather than declared per command, so a newly
78+
added destructive command could be auto-routed by omission. That is the known weakness, and the
79+
routing-table variant above is the fix if it bites.
80+
81+
This decision is proven wrong if auto-routing produces a command the user did not want with
82+
consequences a gate did not catch, or if the destructive enumeration is found stale after a new
83+
command ships.

0 commit comments

Comments
 (0)