diff --git a/docs/architecture/C1-repair-job-authority.md b/docs/architecture/C1-repair-job-authority.md index cc8833b..bc9fabc 100644 --- a/docs/architecture/C1-repair-job-authority.md +++ b/docs/architecture/C1-repair-job-authority.md @@ -217,13 +217,119 @@ filesystem-dependent collision that is characterisable from the strings alone; i establishes nothing about symbolic refs, which are not decidable from a string at all. -**A future trusted repository/Git execution boundary must close the rest.** Before -exercising any ref-mutating authority represented by an `ExecutionPermit`, that -boundary must resolve the requested ref against the actual repository, resolve or -reject repository-dependent symbolic refs, and establish that the repair ref's -resolved target is not the protected parent's. It must **fail closed** — refuse -the operation — if the requested repair ref resolves or dereferences to the -protected parent, or if safe target identity cannot be established at all. +**A future trusted repository/Git execution boundary must close the rest.** C1 +establishes structural canonical ref-name authority; it cannot establish live +repository identity, cannot bind the target a mutation will actually reach, and +cannot enforce anything across a concurrent change. Before acting on any authority +an `ExecutionPermit` records — not only operations that write a ref — that +boundary must satisfy the requirements below, and must **fail closed** — refuse +the operation — wherever a required identity cannot be safely established, +wherever resolution cycles or is otherwise indeterminate, or wherever an effective +identity is or dereferences to a ref the operand's role is not authorized to +denote. + +*The protected-parent rule is role-bound.* Protected-parent identity is forbidden +only where it is unauthorized for the operand's role — which is every role but +one. The effective mutation target of a `repair.commit` or a `repair.push`, and a +`repair.change_request` `sourceRef`, must each be the authorized repair ref, so +for all three an effective identity that is or dereferences to the protected +parent is a refusal. A `repair.change_request` `targetRef` is the single operand +whose *required* effective identity **is** the protected parent ref — the same +operand C1's string layer already singles out as the only one that may name it — +so for that role, and only that role, reaching the protected parent is the +authorized outcome and reaching anything else is the refusal. Stated role-blind +instead, the rule would forbid the one direction the quarantine depends on. No +role widens past this: an operand authorized to denote the protected parent as a +change-request *target* acquires no authority to denote it anywhere else, and the +exemption never reaches an operand that would mutate the parent. + +*Which identity is compared.* The isolation question is about the **effective +ref-name referent** — the terminal ref reached by resolving a symbolic-ref chain — +not about commit-object identity. A freshly created repair branch may legitimately +point at the **same commit object** as the protected parent until its first repair +commit, so distinct commit OIDs are neither necessary nor sufficient: two +different branch refs may share one commit OID, and commit-object equality does not +make two refs the same authority target. The boundary must therefore compare +effective ref-name referents, and must not rest the check on whether two refs +currently resolve to the same commit. What that comparison must *yield* is fixed +by the operand's role: for an operand whose required identity is the authorized +repair ref, a symbolic or effective ref-name identity that aliases the protected +parent must be detected and rejected; for the one operand whose required identity +is the protected parent ref — the `repair.change_request` `targetRef` — the alias +to detect and reject is the converse one, an effective identity that is not the +protected parent ref. + +*Binding the effective mutation target.* A resolved ref *name* is not the target a +mutation will advance, and the boundary must bind the two before it acts: + +- **`repair.commit`.** A commit advances the branch reached through the authorized + worktree's effective `HEAD` referent, not whatever ref name the request carried. + The boundary must bind that effective `HEAD` referent to the authorized repair + ref and refuse to commit if the worktree is detached, attached to the protected + parent, attached to any other ref, or its safe binding cannot be established. +- **`repair.push`.** A push carries both a source and a destination ref, and the + authorized repair ref governs **both**. The boundary must bind the push's + **effective source ref** and its **effective destination ref** — each by its + effective ref-name referent, not by commit-object identity — to the authorized + repair ref, and must not let a caller-selected source or destination refspec + redirect either half. The source must be **present**: an absent source, the + deletion refspec `:refs/heads/…`, is not a `repair.push` at all but a + `branch.delete`, which is denied, so a destination that still names the repair + ref does not make it authorized. No alternate branch, tag, or commit-ish may + stand in for the authorized repair ref on either half. The receiving/mutation + side must fail closed if either effective half is the protected parent, is not + provably the authorized repair ref, or ceases to be between the check and the + push — the authorized source-to-destination relationship must hold through to + that consuming boundary, not only at an earlier pre-check. An ordinary + `refs/heads/repair:refs/heads/repair` push, whose effective source and + destination are both the authorized repair ref, remains authorized. + +*Operands that set direction without mutating a ref.* The obligation is not +limited to ref-mutating operations. `repair.change_request` mutates no ref, but +its `sourceRef` and `targetRef` fix the effective direction of the stacked +validation request, which the quarantine requires to run **from** the repair +branch **to** the protected parent. The boundary must establish that the effective +source identity is the authorized repair ref and the effective target identity is +the protected parent ref, reject a symbolic or effective alias that changes that +direction, and fail closed if either effective identity cannot be safely +established. Because this operation performs no ref update to guard, the boundary +that consumes these identities is the change-request/provider creation — or +update — request itself, and the established source and target identities must be +**bound through to that provider request**: the provider must create the request +from exactly the authorized effective source and target. Provider-side resolution +of the supplied ref names is not itself forbidden — a create/update API may have +to resolve the source and target names against its own authoritative repository +state — but it must yield exactly those authorized effective identities: it must +not let re-resolution, ambient repository state, or any substitution cause the +request to be created from, or to consume, a **materially different** effective +source or target than the one authorized. Resolution that preserves the exact +authorized source-to-target relationship conforms; resolution that would consume +a materially different effective identity does not. If that authorized +source-to-target relationship cannot be maintained through to the provider +request — because an effective identity has changed, cannot be safely +re-established, or cannot be shown equivalent to the authorized one at that +boundary — the boundary must fail closed and create no change request. + +*Concurrency is not closed by a pre-check.* A resolve-then-check-then-act +sequence is **not** an atomic security guarantee: an effective ref or referent can +change between the comparison and the moment the identity is consumed, so a name +observed as an ordinary repair ref can become symbolic to the protected parent — +or a target can cease to denote it — after the check and before the act. The +invariant must be enforced **at the actual trusted execution boundary that +consumes each identity, not only where a ref is mutated**, by a mechanism whose +semantics prevent an unchecked identity change between the comparison and that +consumption — not by an earlier client-side observation the boundary later trusts. +That consuming boundary differs by operation and the obligation is identical at +each: for `repair.commit` it is the commit mutation boundary, for `repair.push` +the push receiving/mutation boundary, and for `repair.change_request` — which +mutates no ref — the change-request/provider creation boundary at which the source +and target identities are actually consumed. An operation whose authorized +effective-identity relationship cannot be held through to its consuming boundary +must fail closed. + +This document states the required invariant, not an implementation: it names no +git command, lock, or transaction mechanism, and it claims no more atomicity than +the eventual executor's own primitives can actually provide. Writing that obligation down adds no runtime git authority to C1 and grants no new authority anywhere: C1 gains no git invocation, no filesystem access, no @@ -244,11 +350,14 @@ authority cannot be checked against an exact operand has no place in the model. | `repair.push` | ref, non-force | ref is exactly the repair branch and the push is not forced | | `repair.change_request` | source ref, target ref | repair branch → protected parent ref | -Every ref operand is read through the same canonical branch-ref reader the job -envelope uses, so "exactly the repair branch" is a claim about a canonical ref -name and not about a caller's chosen spelling. It is not a claim about what that -name resolves to in a repository, which only the later trusted execution boundary -can establish. +Every ref operand — the `repair.commit` and `repair.push` ref, and the +`repair.change_request` source and target refs alike — is read through the same +canonical branch-ref reader the job envelope uses, so "exactly the repair branch" +is a claim about a canonical ref name and not about a caller's chosen spelling. It +is not a claim about the effective ref-name referent that name reaches in a +repository, about which ref a commit or push would actually advance, or about the +effective direction of a change request — all of which only the later trusted +execution boundary can establish. `repair.change_request` is the **only** operation that may name the protected parent ref, and only as a change-request *target*. Opening a change request @@ -454,12 +563,19 @@ actually holds: Forgery therefore buys nothing, and a permit widens no authority — it records authority already derived from trusted configuration. -A permit is also **not a repository-safety finding**. That a `repair.commit` or -`repair.push` ref operand passed C1's canonical syntax validation says nothing -about what that ref resolves to in the repository the operation would touch, so a -permit must never be read as proof that repository-level ref resolution is safe. -The trusted execution boundary that acts on a permit performs its own resolution -and fails closed; see *What canonical ref names do and do not prove* above. +A permit is also **not a repository-safety finding**. That a ref operand — a +`repair.commit` or `repair.push` ref, or a `repair.change_request` `sourceRef` or +`targetRef` — passed C1's canonical syntax validation says nothing about the +effective ref-name referent it reaches in the repository the operation would +touch, about which ref a commit or push would actually advance, or about the +effective direction of a change request, so a permit must never be read as proof +that repository-level ref identity, the effective mutation target, or the +change-request direction is safe. The trusted execution boundary that acts on a +permit binds the effective identity, resolves it, and enforces it at the boundary +that actually consumes that identity — the mutation/receiving boundary for a +`repair.commit` or `repair.push`, and the change-request/provider creation boundary +for a `repair.change_request` — and fails closed; see *What canonical ref names do +and do not prove* above. ### Single use @@ -584,9 +700,12 @@ invariants that stop a later layer from bypassing the quarantine by accident: - The protected parent ref is never a write target of any operation, under any *spelling*: refs are canonical everywhere, so a caller cannot present a textual alias of the parent as a different branch. Repository-dependent aliasing — a - canonical repair ref that is symbolic to the parent — is not visible to a pure - string boundary, and is the later trusted execution boundary's to resolve or - reject before any ref-mutating operation runs. + canonical repair ref that is symbolic to the parent, a worktree `HEAD` or push + destination whose effective target is the parent, a change-request source or + target whose effective direction is reversed, or an effective ref that changes + after a pre-check — is not visible to a pure string boundary, and is the later + trusted execution boundary's to bind, resolve, or reject before it acts on any + authority a permit records. - Filesystem-shaped operations are bound to the repair worktree, so an edit cannot land in the parent's checkout. - The stacked change request must run from the repair branch to the protected diff --git a/src/domain/repair-job.ts b/src/domain/repair-job.ts index 47a1fc9..272885b 100644 --- a/src/domain/repair-job.ts +++ b/src/domain/repair-job.ts @@ -518,15 +518,39 @@ function endsWithDotLockSuffix(value: string, start: number, end: number): boole * than pretended away here. C1 observes no filesystem and cannot do better * than refuse the ambiguous case. * - * The symbolic-ref residue is not narrowable from a string at all. A later - * trusted repository/Git execution boundary must resolve the requested ref - * against the actual repository and **fail closed** — refusing the operation — - * before exercising any ref-mutating authority carried by an `ExecutionPermit`, - * if the repair ref resolves or dereferences to the protected parent or if safe - * target identity cannot be established. Nothing here acquires git invocation, - * filesystem access, a subprocess, or network to decide it. See - * `docs/architecture/C1-repair-job-authority.md`, "What canonical ref names do - * and do not prove". + * The symbolic-ref residue is not narrowable from a string at all, and neither is + * the effective target a mutation would reach. A later trusted repository/Git + * execution boundary must, before acting on any authority an `ExecutionPermit` + * records, resolve the requested ref's effective ref-name referent against the + * actual repository — the terminal ref reached through a symbolic-ref chain, not + * commit-object identity, since a fresh repair branch may legitimately share the + * protected parent's commit OID — and bind each effective identity the operation + * acts on to the authorized repair ref. A commit advances one mutation target: the + * worktree's effective `HEAD` referent, the ref it will actually advance. A push + * binds two effective identities in distinct roles — its destination ref, the + * receiving/mutation target the push advances, and its source ref, the input the + * push consumes to select what is sent — each of which must be the authorized + * repair ref, so an absent (deletion) or redirected source is refused. It must + * **fail closed** — refusing the + * operation — if any such effective identity is or dereferences to the + * protected parent, if it changes between comparison and update, or if it cannot + * be safely established; a resolve-then-act pre-check is not itself atomic, so the + * invariant is enforced at the boundary that actually consumes each identity — the + * mutation/receiving boundary for a commit or push, and the + * change-request/provider creation boundary for a change request. That refusal is + * bound to the operand's role rather than being a blanket ban on the protected + * parent's identity: a `repair.change_request` `targetRef` is *required* to reach + * the protected parent ref, and fails closed when it reaches anything else. Because + * that operation mutates no ref, the identity it consumes is bound at its provider + * create/update request: the effective source must remain the authorized repair ref + * and the effective target the protected parent ref through to that request. The + * provider may resolve those ref names at its own boundary, but must not let + * re-resolution or ambient repository state substitute a materially different + * effective identity for either end, and the boundary must fail closed if the + * authorized relationship cannot be maintained or shown equivalent there. + * Nothing here acquires git invocation, filesystem access, a subprocess, or + * network to decide it. See `docs/architecture/C1-repair-job-authority.md`, + * "What canonical ref names do and do not prove". * * The value is returned exactly as supplied, or not at all. No normalisation, * no prefixing, no case folding: a boundary that repaired the spelling would be @@ -596,8 +620,11 @@ export function readCanonicalBranchRef(value: unknown): string | null { * A `false` result means only that the two names are distinct under this rule. * It is **not** a finding that they denote distinct targets in a repository: this * compares strings and resolves nothing, so a canonical name that is a symbolic - * ref to the other still answers `false` here. Repository-resolved identity is - * the later trusted repository/Git execution boundary's to establish; see + * ref to the other still answers `false` here, and — since two different branch + * refs may legitimately share one commit object — commit-object equality is not + * the question either. Repository-resolved identity, meaning the effective + * ref-name referent reached by resolving a symbolic-ref chain, is the later + * trusted repository/Git execution boundary's to establish; see * {@link readCanonicalBranchRef}. */ function mayDenoteSameBranchRef(left: string, right: string): boolean { @@ -755,7 +782,7 @@ export interface RepairJobAuthorization { * that the two names resolve to distinct targets in a repository — a canonical * repair ref that is symbolic to the parent is invisible to a pure string * boundary — which the later trusted repository/Git execution boundary must - * resolve or reject before any ref-mutating operation runs. See + * resolve or reject before it acts on any authority a permit records. See * {@link readCanonicalBranchRef}. */ readonly repairBranch: string;