From 05d43dde9d5a90f4074b37734598a512f431e36b Mon Sep 17 00:00:00 2001 From: Oliver Scharkowski <59687448+oscharko@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:11:34 +0200 Subject: [PATCH 01/16] feat(git-delivery): add governed Git action contracts, policy packs, and risk semantics (#471) (#1503) Introduce the typed contract foundation for Epic #470 governed Git delivery: three keiko-contracts leaf modules (git-delivery, git-delivery-policy, git-delivery-provider) defining the 10-kind action model, the 4-class risk taxonomy with data-driven severity, the approval-intent model, the lifecycle envelope (resolved inputs / policy decision / approval requirement / preview / result / evidence ref), repo and org policy packs with a deterministic fail-closed evaluator, and provider-neutral branch-protection / checks / pull-request / merge-readiness interfaces. Adds ADR-0058, an operator-facing governance doc, and a keiko-tools boundary test proving the read-only terminal baseline still denies every Git mutation so governed write authority lives only behind these typed contracts (AC5). Enables CI and CodeQL on the feat integration branch by mirroring the existing feat/prompt-enhancer-1307 trigger and protected-branch-gate entries. Refs #471 Co-authored-by: Claude Opus 4.8 --- .github/workflows/ci.yml | 4 +- .github/workflows/codeql.yml | 2 + ...DR-0058-governed-git-delivery-contracts.md | 428 +++++++++ docs/adr/README.md | 1 + docs/git-delivery/governed-git-contracts.md | 194 ++++ .../src/git-delivery-policy.test.ts | 354 ++++++++ .../src/git-delivery-policy.ts | 295 ++++++ .../src/git-delivery-provider.test.ts | 228 +++++ .../src/git-delivery-provider.ts | 200 +++++ .../keiko-contracts/src/git-delivery.test.ts | 545 +++++++++++ packages/keiko-contracts/src/git-delivery.ts | 849 ++++++++++++++++++ packages/keiko-contracts/src/index.test.ts | 74 ++ packages/keiko-contracts/src/index.ts | 131 +++ .../keiko-tools/src/terminal-policy.test.ts | 61 ++ 14 files changed, 3365 insertions(+), 1 deletion(-) create mode 100644 docs/adr/ADR-0058-governed-git-delivery-contracts.md create mode 100644 docs/git-delivery/governed-git-contracts.md create mode 100644 packages/keiko-contracts/src/git-delivery-policy.test.ts create mode 100644 packages/keiko-contracts/src/git-delivery-policy.ts create mode 100644 packages/keiko-contracts/src/git-delivery-provider.test.ts create mode 100644 packages/keiko-contracts/src/git-delivery-provider.ts create mode 100644 packages/keiko-contracts/src/git-delivery.test.ts create mode 100644 packages/keiko-contracts/src/git-delivery.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf03d8fdb..b1549675c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,14 @@ on: - dev - feat/keiko-editor - feat/prompt-enhancer-1307 + - feat/keiko-establish-governed-end-to-end-git-delivery - "release/**" pull_request: branches: - dev - feat/keiko-editor - feat/prompt-enhancer-1307 + - feat/keiko-establish-governed-end-to-end-git-delivery - "release/**" workflow_dispatch: @@ -26,7 +28,7 @@ jobs: - name: Confirm dev branch gate run: | case "${{ github.ref }}:${{ github.base_ref }}" in - refs/heads/dev: | refs/heads/feat/keiko-editor: | refs/heads/feat/prompt-enhancer-1307: | refs/heads/release/*: | *:dev | *:feat/keiko-editor | *:feat/prompt-enhancer-1307 | *:release/*) + refs/heads/dev: | refs/heads/feat/keiko-editor: | refs/heads/feat/prompt-enhancer-1307: | refs/heads/feat/keiko-establish-governed-end-to-end-git-delivery: | refs/heads/release/*: | *:dev | *:feat/keiko-editor | *:feat/prompt-enhancer-1307 | *:feat/keiko-establish-governed-end-to-end-git-delivery | *:release/*) echo "Protected or integration branch gate satisfied." ;; *) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 964227529..4e3fc0d8a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -6,12 +6,14 @@ on: - dev - feat/keiko-editor - feat/prompt-enhancer-1307 + - feat/keiko-establish-governed-end-to-end-git-delivery - "release/**" pull_request: branches: - dev - feat/keiko-editor - feat/prompt-enhancer-1307 + - feat/keiko-establish-governed-end-to-end-git-delivery - "release/**" schedule: - cron: "15 2 * * 0" diff --git a/docs/adr/ADR-0058-governed-git-delivery-contracts.md b/docs/adr/ADR-0058-governed-git-delivery-contracts.md new file mode 100644 index 000000000..b3db39ad8 --- /dev/null +++ b/docs/adr/ADR-0058-governed-git-delivery-contracts.md @@ -0,0 +1,428 @@ +# ADR-0058: Governed Git Delivery Contracts + +## Status + +Proposed + +## Context + +Epic #470 adds end-to-end governed Git delivery to Keiko. Issue #471 is the first child: define the +typed contract surface that every later slice (#472–#479) will build on. Today the product's only +relationship to Git is read-only terminal inspection: `isTerminalCommandAllowed` (keiko-tools, +ADR-0018 D3) allows `git status / diff / log / show / rev-parse / ls-files / describe / blame / +cat-file / branch / remote` and explicitly denies all mutating subcommands (commit, push, merge, +branch creation, etc.). That boundary must remain intact while a new, explicitly governed write +surface is introduced alongside it. + +Three architectural forces require a careful design: + +**Force 1 — No command-string smuggling.** The existing terminal policy works by inspecting +executable + arg strings. A new delivery surface must not be reachable through that path: governed +Git mutations must flow through a typed contract, not through a widened terminal allowlist. + +**Force 2 — Provider neutrality.** GitHub is the first delivery target, but GitHub-specific concepts +(mergeable_state, required_status_checks, check_runs) must not leak into the core domain. Provider +adapters map the provider shape to a neutral interface; the domain never sees provider vocabulary. + +**Force 3 — Leaf-package purity.** keiko-contracts is a strict dependency leaf (ADR-0019 direction +rule 1): no @oscharko-dev/* imports, no IO, no clock, no crypto, no randomness. All contracts and +validators must be pure functions over plain JSON. + +### Existing vocabulary to compose + +- `isApprovalTokenShape(token)` — validates a 64-hex-char SHA-256 approval token + (`workflow-handoff.ts`). The Git approval model reuses this shape. +- `EvidenceGovernedWorkflowHandoff` — references evidence by opaque id/hash only (content-free). + Git evidence references follow the same pattern. +- `WorkflowHandoffRequest` — a one-shot agent invocation envelope. Git delivery is multi-step + (preview → approve → execute → result), so a new lifecycle envelope is required; the two shapes + must not be confused. + +### Scope boundary (Issue #471) + +This ADR covers only the contract surface. It does not cover: + +- Live Git execution engine (Issue #472+) +- UI components beyond wire-type payloads (Issue #473+) +- Credential or provider-API integration (Issue #474+) +- Evidence persistence beyond contract hook shapes (Issue #476+) + +## Decision + +We will introduce three new modules in `packages/keiko-contracts/src/`: + +1. **`git-delivery.ts`** — action kinds, per-kind resolved inputs, risk-class taxonomy (with + explicit ordinal severity and a frozen kind→risk-class default table), approval-intent model, and + the composite lifecycle envelope covering resolved inputs / policy decision / approval requirement + / preview / execution result / evidence reference. + +2. **`git-delivery-policy.ts`** — repo-level and org-level policy-pack structures, typed rule shape + (decision = `allowed | blocked | approval-gated | constrained`), an optional authorable + `defaultRule` (deny-by-default as data), and a deterministic pure evaluator `evaluateGitPolicy` + with a complete documented precedence matrix and a genuine fail-closed default. + +3. **`git-delivery-provider.ts`** — provider-neutral interfaces for branch protection state, checks + summary, pull-request state, merge readiness, remote-target policy, and provider-capability + descriptor. No GitHub field names appear in these interfaces. + +The three-file split reflects three different rates of change: provider shapes track GitHub API +evolution; policy-pack structures track org/repo governance requirements; core action kinds and risk +classes change infrequently as the domain matures. + +**Module ownership and the acyclic graph.** `git-delivery.ts` is the core atom and imports nothing +from its two siblings; it owns the action kinds, risk taxonomy, the lifecycle envelope, the typed +constraint union, the policy decision, the provider-capability enum, the typed branch-pattern +matchers, the risk-class-ceiling helper, and the shared `GitDeliveryParseResult`. +`git-delivery-policy.ts` and `git-delivery-provider.ts` import only from `git-delivery.ts`. The +resulting one-directional DAG has no cycles (verified by `arch:check`). The only internal import in +`git-delivery.ts` is `./workflow-handoff.js` for `isApprovalTokenShape` (a legal intra-package +relative import). + +We will add all public exports to `packages/keiko-contracts/src/index.ts` under a `#471` block and +add the corresponding pin assertions to `packages/keiko-contracts/src/index.test.ts`. + +The AC5 boundary test will live in `packages/keiko-tools/src/terminal-policy.test.ts` (new +`describe` block) asserting that the mutating subcommands the terminal policy denies remain denied +and that `GIT_DELIVERY_ACTION_KINDS` — the typed source of truth for governed actions — is a +structurally separate surface not accessible through `isTerminalCommandAllowed`. + +### D1 — Action kinds and risk taxonomy (AC1, AC2) + +`GitDeliveryActionKind` is a string-literal union; `GIT_DELIVERY_ACTION_KINDS` is its frozen array +source of truth. The ten kinds are: + +``` +branch-create | stage | unstage | commit | push +pr-create | pr-update | merge | abort | recovery +``` + +`GitDeliveryRiskClass` is a four-member union with an explicit ordinal severity field (not derived +from the name): + +| Class | Ordinal | Coverage | +|---|---|---| +| `local-mutation` | 1 | branch-create, stage, unstage, commit, abort | +| `publish` | 2 | push | +| `protected-or-merge` | 3 | pr-create, pr-update, merge | +| `recovery-or-rewrite` | 4 | recovery | + +`GIT_DELIVERY_ACTION_RISK_DEFAULTS` is a frozen `Record` +that maps every kind to its default risk class. An unknown kind (future extension, deserialized from +JSON before a schema migration) receives `recovery-or-rewrite` (ordinal 4, highest) from the +fail-closed evaluator rather than crashing. + +Severity is DATA (read `GIT_DELIVERY_RISK_CLASS_SEVERITY[riskClass]`), not name-inference. No +downstream code may infer severity by inspecting the action kind string or shell arguments. + +### D2 — Approval-intent model (AC1) + +`GitDeliveryApprovalRequirement` is a discriminated union on `required: boolean`. When +`required: true`, the record carries: + +- `approvalTokenHash: string` — a 64-hex-char SHA-256 hash of the approval token (never the token + itself; validated by `isApprovalTokenShape`). +- `approvedByUserId: string` — opaque user identity reference. +- `approvedAtMs: number` — epoch-ms timestamp. +- `expiresAtMs: number | undefined` — optional expiry. + +When `required: false`, only `required` is present. This keeps approval intent structurally +distinct from the absence of approval, so pattern-matching is exhaustive. + +### D3 — Lifecycle envelope (AC1) + +The envelope is a **sound discriminated union**, not a phantom generic. `GitDeliveryActionEnvelopeFor` +is parameterised by a single per-kind resolved-input type `I`, with `kind: I["kind"]` and +`resolvedInputs: I`, so `kind === resolvedInputs.kind` holds by construction. +`GitDeliveryActionEnvelope` is the union over all ten per-kind members. Both are exported. The +envelope composes the six elements required by AC1: + +1. `resolvedInputs: I` — per-kind typed inputs (discriminated on `kind`). +2. `policyDecision: GitDeliveryPolicyDecision` — outcome of policy evaluation (see D4). +3. `approvalRequirement: GitDeliveryApprovalRequirement` — whether human approval is required. +4. `preview: GitDeliveryActionPreview | undefined` — content-free preview descriptor (counts, + flags, affected-branch name — no diff content, no secrets, no raw paths). +5. `executionResult: GitDeliveryExecutionResult | undefined` — outcome populated after execution + lands. +6. `evidenceRef: GitDeliveryEvidenceRef | undefined` — content-free reference; never raw diffs or + command output. + +`parseGitDeliveryActionEnvelope` additionally enforces at runtime that +`value.kind === value.resolvedInputs.kind`, rejecting any envelope whose discriminant disagrees with +its inputs. `GitDeliveryResolvedInputs` is a discriminated union on `kind`. Each member is a separate +interface (`GitDeliveryBranchCreateInputs`, `GitDeliveryStageInputs`, etc.) carrying only the +semantically typed fields that kind requires. No kind leaks another kind's fields. Recovery's +elevated-approval requirement lives on the envelope (`policyDecision` / `approvalRequirement`), not in +its inputs. + +`GitDeliveryExecutionResult.errorCode` is a closed `GitDeliveryExecutionErrorCode` union +(`provider-rejected | network-failure | conflict | precondition-failed | timeout | internal-error`), +not a free string, and carries an optional `partialDetail` (attempted/succeeded unit counts) for the +`partial` outcome. `GitDeliveryEvidenceRef` names align with `evidence.ts` +(`sourceGroundedRunId` + `evidenceManifestStableIdHash`). + +### D4 — Policy decision and policy packs (AC2, AC3) + +`GitDeliveryPolicyDecision` is a discriminated union on `outcome`: + +- `{ outcome: "allowed" }` — action may proceed. +- `{ outcome: "blocked"; reason: GitDeliveryBlockReason }` — typed block reason (not a free string). +- `{ outcome: "approval-gated"; requiredApprovers: readonly string[] }` — lists opaque approver + ids; execution is held until approval is recorded. +- `{ outcome: "constrained"; constraints: readonly GitDeliveryConstraint[] }` — action may + proceed only after listed typed constraints are satisfied. + +`GitDeliveryConstraint` is a discriminated union on `kind`: + +- `{ kind: "branch-pattern"; patterns: readonly GitDeliveryBranchPattern[] }` — the target branch + must match at least one **structured** pattern. A `GitDeliveryBranchPattern` is typed data + (`{ matchKind: "exact" | "prefix"; value: string }`), not a parsed string. Glob is **intentionally + excluded** to keep the leaf parse-free; `gitDeliveryBranchNameMatchesPattern` / + `gitDeliveryBranchNameMatchesAny` are the deterministic matchers. If a later issue needs glob, it + adds a third `matchKind` with its own matcher, never an embedded mini-language. +- `{ kind: "provider-capability"; capability: GitDeliveryProviderCapability }` — the active + provider must advertise the required capability (see D5). +- `{ kind: "risk-class-ceiling"; maxRiskClass: GitDeliveryRiskClass }` — a genuine ceiling: an + action whose default risk-class severity **exceeds** `maxRiskClass` is out of bounds. + `gitDeliveryRiskClassWithinCeiling(actionKind, ceiling)` compares severities via the frozen + ordinal table. (This replaces the misnamed/circular `min-risk-class` constraint.) + +There are no free-form string constraint payloads. Every constraint kind is a typed discriminant +(AC3). + +`GitDeliveryBlockReason` is a string-literal union (not a string): + +``` +"policy-pack-blocked" | "protected-branch" | "provider-capability-absent" +| "approval-expired" | "risk-class-ceiling" | "no-applicable-rule" +``` + +The sixth reason, `no-applicable-rule`, is the genuine fail-closed case (neither level had a rule or +a `defaultRule`); it is distinct from an explicit `approval-gated` rule with empty approvers. + +### D5 — Policy packs (AC3) + +`GitDeliveryRepoPolicyPack` and `GitDeliveryOrgPolicyPack` share the same rule structure +(`GitDeliveryPolicyRule`) but differ in scope: + +```typescript +interface GitDeliveryPolicyRule { + readonly actionKind: GitDeliveryActionKind; + readonly decision: GitDeliveryRuleDecision; + readonly requiredApprovers?: readonly string[] | undefined; // when "approval-gated" + readonly constraints?: readonly GitDeliveryConstraint[] | undefined; // when "constrained" +} + +interface GitDeliveryDefaultRule { + readonly decision: GitDeliveryRuleDecision; + readonly requiredApprovers?: readonly string[] | undefined; + readonly constraints?: readonly GitDeliveryConstraint[] | undefined; +} + +type GitDeliveryRuleDecision = "allowed" | "blocked" | "approval-gated" | "constrained"; +``` + +Both packs carry an optional `defaultRule`. An org or repo authors deny-by-default as data with +`defaultRule: { decision: "blocked" }`. `defaultRule` is validated by the pack parsers with the same +per-decision required fields as a normal rule. + +**Per-level resolution.** For a given context, each level resolves to one of +`{ allowed, blocked, approval-gated(approvers), constrained(constraints), none }`: the rule whose +`actionKind` matches wins; otherwise the level's `defaultRule` applies; otherwise the level is +`none`. + +**Combination matrix (first match wins; org = O, repo = R).** `evaluateGitPolicy(orgPack, repoPack, +context)` is a pure function (no IO, no clock) implemented via the `resolveLevel` + `combineDecisions` +helpers: + +1. `O == blocked` **or** `R == blocked` → `{ outcome: "blocked", reason: "policy-pack-blocked" }`. +2. `O == approval-gated` → `{ outcome: "approval-gated", requiredApprovers: }`. +3. `R == approval-gated` → `{ outcome: "approval-gated", requiredApprovers: }`. +4. `O == constrained` **or** `R == constrained` → `{ outcome: "constrained", constraints: }`. +5. `O == allowed` **or** `R == allowed` → `{ outcome: "allowed" }`. +6. else (both `none`) → `{ outcome: "blocked", reason: "no-applicable-rule" }` — fail-closed. + +Either level can tighten; org tightening dominates a repo loosen; empty packs fail closed. +`requiredApprovers: []` on an **explicit** `approval-gated` rule means "at least one approver of any +identity" and is NOT the fail-closed case — the fail-closed case is `no-applicable-rule` blocked. + +`parseGitPolicyPack` / `parseGitRepoPolicyPack` / `parseGitOrgPolicyPack` return the shared +`GitDeliveryParseResult`. `parseGitPolicyPack` discriminates a repo pack from an org pack by the +presence of `repoId` vs `orgId`. + +### D6 — Provider-neutral interfaces (AC4) + +The four neutral interfaces are: + +| Interface | Covers | +|---|---| +| `GitDeliveryBranchProtection` | Deletion allowed, force-push allowed, linear history required, review count required, status checks required (count only, not names) | +| `GitDeliveryChecksState` | Total, passing, failing, pending (counts); overall status as `passing | failing | pending | skipped` | +| `GitDeliveryPullRequestState` | Neutral PR status: `open | closed | merged` (draftness is the orthogonal `isDraft: boolean`, not a status member); base branch name; head branch name; merge readiness reference | +| `GitDeliveryMergeReadiness` | Ready boolean; blocking reason as `GitDeliveryMergeBlockReason` (typed union, not a string); required approval count (number) | +| `GitDeliveryRemoteTargetPolicy` | Allowed push targets as readonly string[]; force-push globally denied boolean | +| `GitDeliveryProviderCapability` | Named capability (`branch-protection | draft-pr | required-checks | merge-queue | protected-branch-delete`) — what the connected provider supports | + +**The rule that keeps providers out of the core:** No interface in `git-delivery-provider.ts` may +use a field name, value, or type drawn from a specific provider's API documentation. Provider +adapters (in keiko-workflows or keiko-server, not in keiko-contracts) are responsible for mapping +provider responses to these neutral interfaces. This rule is enforced by code review and by the fact +that keiko-contracts is a leaf package: it cannot import a provider SDK. + +GitHub-to-neutral field mapping (documented here so provider adapters have a reference): + +| GitHub API field | Neutral field | Location | +|---|---|---| +| `protected` | (drives whether `GitDeliveryBranchProtection` is present) | branch endpoint | +| `allow_deletions.enabled` | `GitDeliveryBranchProtection.deletionAllowed` | branch protection | +| `allow_force_pushes.enabled` | `GitDeliveryBranchProtection.forcePushAllowed` | branch protection | +| `required_linear_history.enabled` | `GitDeliveryBranchProtection.linearHistoryRequired` | branch protection | +| `required_pull_request_reviews.required_approving_review_count` | `GitDeliveryBranchProtection.requiredReviewCount` | branch protection | +| `required_status_checks` (presence + count) | `GitDeliveryBranchProtection.requiredStatusCheckCount` | branch protection | +| `mergeable` + `mergeable_state` | `GitDeliveryMergeReadiness.ready` + `.blockingReason` | PR endpoint | +| `draft` | `GitDeliveryPullRequestState.isDraft` (orthogonal boolean) | PR endpoint | +| `check_runs[*].conclusion` (aggregated) | `GitDeliveryChecksState.{passing,failing,pending,total}` + `.overallStatus` | check-runs endpoint | + +### D7 — AC5 boundary enforcement + +Two enforcement mechanisms: + +**Mechanism 1 — Terminal policy immutability.** The terminal allowlist for `git` in +`terminal-policy.ts` must not grow to include any mutating subcommand. The test file +`terminal-policy.test.ts` gains a new `describe` block (`"AC5 — governed Git delivery boundary +(ADR-0058)"`) that asserts `isTerminalCommandAllowed("git", [...])` returns `allowed: false` for the +REAL underlying mutating/network commands each governed action kind maps to — not a vacuous +kind-name loop. The covered invocations include: + +``` +commit -m x +push origin main push --force origin main +merge feat merge --abort +branch feat/x +add . add -A (the real stage commands) +restore --staged . reset HEAD file (the real unstage commands) +reset --hard HEAD~1 restore . (recovery) +rebase main cherry-pick abc revert abc stash clean -fd tag v1 +switch -c x checkout -b x fetch pull (rewrite-adjacent / network) +``` + +A positive control asserts read-only inspection stays allowed (`git status`, `git log` → +`allowed: true`), proving the boundary is selective rather than deny-everything. + +**Mechanism 2 — Typed surface separation.** The test asserts that `GIT_DELIVERY_ACTION_KINDS` is a +typed array value importable from `@oscharko-dev/keiko-contracts` and that none of its members +appear as values in `TERMINAL_COMMAND_RULES[*].allowedSubcommands`. This ensures the governed kind +surface and the terminal allowlist are structurally disjoint. + +## Consequences + +### Positive + +- Risk class is DATA embedded in a frozen lookup table; no code ever infers severity from subcommand + names or shell arguments (AC2 met by construction). +- Policy evaluator is pure and deterministic; the same input always produces the same decision, + making audit traces reproducible. +- Provider-neutral interfaces mean the GitHub adapter can be replaced or supplemented (GitLab, + Gitea) without touching core domain types. +- The lifecycle envelope composes AC1's six required elements; as a sound discriminated union, + `kind === resolvedInputs.kind` holds by construction and is re-checked at parse time, so a kind + cannot be paired with another kind's inputs. +- The terminal allowlist remains read-only; widening it to enable Git mutations is architecturally + impossible without touching both keiko-tools and keiko-contracts separately — two files, two + reviewers. +- Approval tokens reuse the existing `isApprovalTokenShape` pattern; no new token format to audit. + +### Negative + +- Policy packs must be pre-composed before reaching the evaluator (no runtime DB lookups in the + contract layer). keiko-server or keiko-workflows must assemble the packs from storage before + calling `evaluateGitPolicy`. This is correct by the leaf-package rule but is a constraint later + slices must respect. +- Three new contract files add indexing and barrel surface cost; each new action kind added in + future issues requires coordinated additions to `GIT_DELIVERY_ACTION_KINDS`, + `GIT_DELIVERY_ACTION_RISK_DEFAULTS`, and per-kind resolved-input discriminant members. +- Provider adapter authors must maintain the GitHub-to-neutral mapping table as the GitHub API + evolves. Drift is a known risk. + +### Neutral + +- The three-file split (`delivery`, `policy`, `provider`) means a single change to add a new + action kind touches delivery.ts and policy.ts but not provider.ts. A new provider capability + touches only provider.ts. +- `evaluateGitPolicy` takes org and repo packs as separate parameters (not a merged pack) so call + sites cannot accidentally merge them before calling and lose the precedence ordering. +- The `GitDeliveryActionEnvelope` union (over `GitDeliveryActionEnvelopeFor` members) avoids the + earlier phantom-generic / conditional-type construction. It costs one envelope union member per + action kind, which is mechanical to extend and free of the per-call-site conditional-type + instantiation cost. + +## Alternatives Considered + +### Alternative 1: Single `git-delivery.ts` file for all three concerns + +- **Pros**: Fewer files; simpler initial import graph; less barrel boilerplate. +- **Cons**: Provider shapes, policy-pack structures, and core action kinds have different rates of + change and different owners. A GitHub API field name change would require editing the same file + as a risk-class ordinal update. At the scale of 10+ action kinds, provider neutrality would erode + by convenience. +- **Why rejected**: Separation of concerns principle; the three concerns are genuinely independent. + The additional files are a one-time cost; the benefit is permanent. + +### Alternative 2: Reuse `WorkflowHandoffRequest` as the lifecycle envelope + +- **Pros**: Reuses established approval-token and evidence-reference patterns; no new envelope type; + implementors already know the shape. +- **Cons**: `WorkflowHandoffRequest` is a one-shot pre-flight envelope for an agent invocation. Git + delivery is multi-step: the lifecycle has a preview phase (before execution), an approval phase + (may be async), an execution phase, and a result phase. The `patchScope` and `expectedChecks` + fields of `WorkflowHandoffRequest` are semantically wrong for a push or a PR merge. Structural + confusion between the two shapes would produce runtime errors that type-checking cannot catch. +- **Why rejected**: The shapes are semantically incompatible. The approval-token hash and evidence + reference patterns are reused; the envelope shape is not. + +### Alternative 3: Stringly-typed policy packs with free-form constraint strings + +- **Pros**: Maximally flexible; operators can express any constraint without a schema change; + simpler initial type definition. +- **Cons**: Free-form strings cannot be validated at the contract layer; every consumer must parse + and interpret strings independently, producing multiple subtly incompatible parsers. Audit code + cannot reason about constraints without string-matching, which is fragile and misleading (AC3 + explicitly prohibits this). Any typo in a constraint string silently falls through. +- **Why rejected**: AC3 explicitly requires typed constraints. The typed constraint union + (`branch-pattern | provider-capability | risk-class-ceiling`) covers the concrete use cases from + Issue #471 scope with room to add members as later issues require. Branch patterns themselves are + structured data (`exact | prefix`), not embedded glob strings — glob is intentionally excluded to + keep the leaf parse-free. + +### Alternative 4: Embed GitHub field names with a translation layer in the same file + +- **Pros**: Explicit mapping is readable in one place; no separate adapter layer required for the + first delivery. +- **Cons**: GitHub field names in keiko-contracts would make every consumer aware of GitHub specifics. + A field name change in the GitHub API (or addition of a second provider) would force a breaking + change in the leaf package, cascading to all consumers. The AC4 criterion explicitly requires + provider neutrality in core contracts. +- **Why rejected**: AC4. Provider adapters belong in keiko-workflows or keiko-server, which can + import both the contracts and provider SDKs. + +### Alternative 5: Risk class as a numeric constant, no named taxonomy + +- **Pros**: Simpler comparisons (`riskOrdinal >= 3`); no union type to maintain. +- **Cons**: Numbers carry no documentation; a `3` in an audit log is meaningless without the + source. Discriminated string literals (`"protected-or-merge"`) are self-documenting in logs, in + UI labels, and in policy-pack rule conditions. Adding a new class between existing ordinals + requires renumbering. +- **Why rejected**: Named taxonomy with an explicit ordinal field (`GIT_DELIVERY_RISK_CLASS_SEVERITY`) + gives both documentation (the name) and arithmetic (the ordinal) without coupling them. + +## Related + +- ADR-0019: Modular Package Architecture (leaf-package rule, dependency direction) +- ADR-0018: Terminal allowlist (read-only Git baseline being preserved) +- ADR-0043: Enforced Execution Isolation (boundary enforcement model) +- Issue #471: Define governed Git action contracts, policy packs, and risk semantics (this ADR) +- Issue #470: Epic — governed end-to-end Git delivery +- Issues #472–#479: Later children that build on these contracts + +## Date + +2026-06-25 diff --git a/docs/adr/README.md b/docs/adr/README.md index 96c9a6142..8e1d16604 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -45,6 +45,7 @@ This page keeps only the product decisions needed by reviewers. It is not an imp | Context-engineering integration: orchestrator and harness wiring | [ADR-0055](ADR-0055-context-engineering-orchestrator-harness-wiring.md) defines PR4 — the first behavior-affecting integration wave: a non-mutating diagnostics observer on the grounded pack (`grounded-context-diagnostics.ts`) attaches `ContextAssemblyDiagnostics` to `ContextPackDiagnostics.contextBudget?` (AC5 byte-identical: `promptBudgetedMessages` does not read `diagnostics`); a history-compaction splice (`conversation-compaction.ts`) replaces silent hard-drop with a provenance-backed summary segment for sessions > `MAX_CONTEXT_MESSAGES = 24` (short-session guard: splice is a no-op at or below threshold and when `contextProfile` is absent); shaped observations attached to `ToolCallResult.shapedObservation?` in the harness via an injected `HarnessShaperPort` (no new `keiko-harness → keiko-workflows` edge); `ContextProfile` provisioned in `UiHandlerDeps` (default `DEFAULT_CONTEXT_PROFILE`, absent-guard keeps all existing tests byte-identical); single unchanged-guarantee predicate `contextProfile === undefined OR rawHistory.length <= MAX_CONTEXT_MESSAGES`; compaction records in-memory only (PR5 persists); shaped-output substitution and lane eviction deferred; ten measurable gates including `ac5ByteIdentical`, `shortSessionByteIdentical`, `longSessionCompaction`, `diagnosticsPresent`, `firstRingPreserved` (Status: Proposed). | | Regulated evidence for context assembly and compaction diagnostics | [ADR-0056](ADR-0056-regulated-evidence-context-assembly-compaction.md) defines PR5 — additive `EvidenceManifest.contextAssembly?: ContextAssemblyDiagnostics` and `compaction?: readonly ContextCompactionRecord[]` (no schema-version bump, mirrors `connectedContext?`/`governedHandoff?` precedent); no new `EvidenceTaskType` (diagnostic fields on the existing manifest, not a new run kind); grounded path live-wired (`pack.diagnostics?.contextBudget` flows into `persistConnectedContextEvidence` in W3); chat path contract-ready via a new unit-tested `persistCompactionEvidence` helper (no live wire in PR5 — per-turn `runId` strategy and latency analysis deferred to PR6); two-layer redaction (`createAuditRedactor` field-level + `deepRedactStrings` whole-object); `workspaceRootAuditId` hashing; `validateManifestShape` extended with `requireOptionalRecord`/`requireOptionalArray` for both fields; `buildEvidenceReport` and browser summaries structurally unaffected; seven measurable gates; tool-result artifact persistence, UI disclosure, and live chat wiring deferred to PR6 (Status: Proposed). | | Context-engineering UI summary panel and chat-compaction evidence wiring | [ADR-0057](ADR-0057-context-status-panel-and-chat-compaction-wiring.md) defines PR6 — the milestone-closing PR: additive `GroundedAnswerContextSummary` (totalEstimatedTokens, budgetPressure, laneCounts as Record of 8 fixed lane names → count, compactionActive boolean) on `GroundedAnswerContextPackSummary`; `buildGroundedAnswerContextPackSummary` extended to accept optional `ContextAssemblyDiagnostics` as a fourth parameter (existing 3-arg callers unchanged); new `ContextStatusPanel` component (`
/` collapsed-by-default, matching RankingRationale pattern, MetricRow + formatTokens reuse, null when contextSummary absent); `.ctx-*` classes in globals.css pinned by globals.css.test.ts; path-free guarantee structural (no `string` field in the type) + `not.toContain("/")` test gate + jest-axe a11y gate; chat-compaction wiring via new private `deriveCompactionOutcome` helper (buildGatewayMessages signature unchanged), `persistCompactionEvidence` called best-effort post-turn with runId `chat-{sha256Hex(chatId).slice(0,16)}-t{messageCount}` (assertValidRunId-safe), DEFAULT_RETENTION; W1=contracts, W2=UI, W3=server-wiring, W4=coordinator browser verification (Status: Proposed). | +| Governed Git delivery contracts | [ADR-0058](ADR-0058-governed-git-delivery-contracts.md) defines the typed contract surface for Epic #470 governed Git delivery (Issue #471): three new keiko-contracts modules (`git-delivery.ts` as the acyclic core atom, plus `git-delivery-policy.ts` and `git-delivery-provider.ts` importing only from it); a 10-kind `GitDeliveryActionKind` union with a 4-class risk taxonomy (`local-mutation/publish/protected-or-merge/recovery-or-rewrite`) where severity is DATA in a frozen lookup table (not name-inference, with force-push escalation via `gitDeliveryRiskClassForInputs`); a sound discriminated-union `GitDeliveryActionEnvelope` composing the 6 AC1 lifecycle elements (`kind === resolvedInputs.kind` enforced at parse time); a deterministic pure `evaluateGitPolicy` with a complete precedence matrix, deny-by-default `defaultRule`, and a genuine fail-closed `no-applicable-rule`; typed `GitDeliveryConstraint` discriminants including structured `exact/prefix` branch patterns (glob intentionally excluded) and a genuine `risk-class-ceiling`; a closed `GitDeliveryExecutionErrorCode`; and provider-neutral interfaces for branch protection, checks, PR state (with orthogonal `isDraft`), and merge readiness with an explicit GitHub-to-neutral mapping table. The read-only terminal baseline (`isTerminalCommandAllowed`) is preserved intact and machine-checked against the real mutating git commands; governed write authority lives only behind these contracts (Status: Proposed). | ## Historical Records diff --git a/docs/git-delivery/governed-git-contracts.md b/docs/git-delivery/governed-git-contracts.md new file mode 100644 index 000000000..eddb7a814 --- /dev/null +++ b/docs/git-delivery/governed-git-contracts.md @@ -0,0 +1,194 @@ +# Governed Git Delivery Contracts + +This document describes the governed Git delivery contract surface introduced in Issue #471 +(Epic #470) and defined by [ADR-0058](../adr/ADR-0058-governed-git-delivery-contracts.md). It is +written for operators who author policy packs and for engineers who build the later delivery slices +(#472 and beyond) on top of these contracts. + +## 1. Overview + +Governed Git delivery is a typed contract layer that describes Git mutations — branch creation, +staging, commits, pushes, pull requests, merges, aborts, and recovery — as structured data that +flows through an explicit lifecycle: preview, policy evaluation, approval, execution, evidence. + +It is **not** a command runner. The contracts carry no shell strings, no diff content, no file +paths, and no secrets. They describe _what a governed action is_ and _whether policy permits it_; +the actual execution engine, provider adapters, credential handling, and UI are delivered by later +issues. The read-only terminal inspection baseline (`isTerminalCommandAllowed`) is unchanged: it +still denies every mutating `git` subcommand. Governed write authority lives only behind these typed +contracts, never behind a widened terminal allowlist (see Section 6). + +The surface lives in three leaf modules in `packages/keiko-contracts/src/`: + +- `git-delivery.ts` — the core atom: action kinds, risk taxonomy, the lifecycle envelope, the typed + constraint union, the policy decision, the provider-capability enum, the branch matchers, and the + shared parse-result type. +- `git-delivery-policy.ts` — policy packs and the deterministic pure evaluator. +- `git-delivery-provider.ts` — provider-neutral interfaces for branch protection, checks, pull + requests, and merge readiness. + +All three are pure: no IO, no clock, no randomness, no crypto. The policy evaluator is a pure +function — the same inputs always yield the same decision. + +## 2. Action model + +There are ten action kinds (`GIT_DELIVERY_ACTION_KINDS`). Each carries only the typed inputs that +kind requires; no kind leaks another kind's fields. + +| Kind | What it represents | Notable inputs | +| --------------- | ----------------------------------------- | --------------------------------------------------------------------- | +| `branch-create` | Create a branch from a start point | `branchName`, `baseBranchName`, `startPointRefHash` | +| `stage` | Stage changes for commit | `pathCount`, `includesUntracked` | +| `unstage` | Remove changes from the index | `pathCount` | +| `commit` | Record a commit | `messageByteLength`, `stagedPathCount`, `allowEmptyCommit` | +| `push` | Publish a branch to a remote | `remoteAlias`, `remoteBranchName`, `forcePush`, `setUpstreamTracking` | +| `pr-create` | Open a pull request | `headBranchName`, `baseBranchName`, `isDraft` | +| `pr-update` | Update a pull request | `prExternalId`, `convertToDraft`, `convertFromDraft` | +| `merge` | Merge a pull request | `prExternalId`, `mergeStrategyHint`, `deleteBranchAfterMerge` | +| `abort` | Abort an in-progress operation | `operationToAbort`, `preserveIndexChanges` | +| `recovery` | Reset / restore / stash-and-reset history | `recoveryStrategyHint`, `targetRefHash`, `affectedPathCount` | + +Inputs that would otherwise carry content (commit messages, PR bodies, file paths) are reduced to +counts and byte lengths. This keeps the contract layer content-free while preserving enough signal +for policy and preview. + +### Lifecycle envelope + +`GitDeliveryActionEnvelope` composes the six elements an action carries through its lifecycle: +`resolvedInputs`, `policyDecision`, `approvalRequirement`, optional `preview`, optional +`executionResult`, and optional `evidenceRef`. It is a sound discriminated union: each member pairs a +single per-kind input type with a matching `kind`, so `kind === resolvedInputs.kind` holds by +construction. `parseGitDeliveryActionEnvelope` re-checks that invariant at runtime and rejects any +envelope whose discriminant disagrees with its inputs. + +The execution result carries a closed `errorCode` (`provider-rejected`, `network-failure`, +`conflict`, `precondition-failed`, `timeout`, `internal-error`) — never a free string — and an +optional `partialDetail` (attempted/succeeded unit counts) for the `partial` outcome. The evidence +reference names align with `evidence.ts` (`sourceGroundedRunId`, `evidenceManifestStableIdHash`). + +## 3. Risk taxonomy + +There are four risk classes (`GIT_DELIVERY_RISK_CLASSES`), each with an explicit ordinal severity in +the frozen `GIT_DELIVERY_RISK_CLASS_SEVERITY` table: + +| Class | Ordinal | Default coverage | +| --------------------- | ------- | -------------------------------------------- | +| `local-mutation` | 1 | branch-create, stage, unstage, commit, abort | +| `publish` | 2 | push | +| `protected-or-merge` | 3 | pr-create, pr-update, merge | +| `recovery-or-rewrite` | 4 | recovery | + +**Severity is data, never name-inference.** Compare risk by reading the ordinal from the table; no +code infers severity from an action-kind string or from shell arguments. + +- `gitDeliveryDefaultRiskClass(kind)` returns the default class, failing closed to + `recovery-or-rewrite` (the highest) for any unknown kind. +- `gitDeliveryRiskClassForInputs(inputs)` returns the default class except that a `push` with + `forcePush: true` escalates to `recovery-or-rewrite`, so a force-push is never under-classified. +- `gitDeliveryRiskClassWithinCeiling(actionKind, ceiling)` returns true when the action's default + severity is at or below the ceiling's severity. + +## 4. Policy packs + +Policy is authored as two packs — an org pack and a repo pack — each a list of rules plus an optional +`defaultRule`. A rule binds an `actionKind` to a `decision`: + +- `allowed` — the action may proceed. +- `blocked` — the action is denied. +- `approval-gated` — the action is held until approval is recorded; `requiredApprovers` lists opaque + approver ids (an empty list means "at least one approver of any identity"). +- `constrained` — the action may proceed only after the listed typed constraints are satisfied. + +Typed constraints (`GitDeliveryConstraint`) have no free-form strings: + +- `branch-pattern` — the target branch must match at least one structured pattern. A pattern is + `{ matchKind: "exact" | "prefix", value }`. Glob is intentionally excluded to keep the leaf + parse-free; use `gitDeliveryBranchNameMatchesAny` to evaluate. +- `provider-capability` — the active provider must advertise the named capability. +- `risk-class-ceiling` — actions whose default severity exceeds `maxRiskClass` are out of bounds. + +**Deny-by-default as data.** Set `defaultRule: { decision: "blocked" }` on a pack so that any action +kind without a specific rule is denied at that level. + +### Precedence + +`evaluateGitPolicy(orgPack, repoPack, context)` resolves each level to one of +`allowed / blocked / approval-gated / constrained / none` (matching rule first, then `defaultRule`, +then `none`), and combines them by this total matrix (first match wins; O = org, R = repo): + +1. `O == blocked` or `R == blocked` → **blocked** (`policy-pack-blocked`). +2. `O == approval-gated` → **approval-gated** with org approvers. +3. `R == approval-gated` → **approval-gated** with repo approvers. +4. `O == constrained` or `R == constrained` → **constrained** (org constraints first, then repo). +5. `O == allowed` or `R == allowed` → **allowed**. +6. else (both `none`) → **blocked** (`no-applicable-rule`) — fail-closed. + +Either level can tighten; org tightening dominates a repo loosen; empty packs fail closed. The +fail-closed `no-applicable-rule` is distinct from an explicit `approval-gated` rule with empty +approvers. + +### Worked examples + +**Allowed.** Org allows `push`; repo has no `push` rule → `{ outcome: "allowed" }`. + +**Blocked (org wins over a repo loosen).** Org blocks `recovery`; repo allows `recovery` → +`{ outcome: "blocked", reason: "policy-pack-blocked" }`. + +**Approval-gated.** Org gates `push` with `requiredApprovers: ["org-lead"]`; repo allows `push` → +`{ outcome: "approval-gated", requiredApprovers: ["org-lead"] }`. + +**Constrained (union).** Org constrains `push` with a `provider-capability` constraint; repo +constrains `push` with a `risk-class-ceiling` constraint → `{ outcome: "constrained", constraints: +[, ] }`. + +**Deny-by-default.** Org `defaultRule: { decision: "blocked" }` and no `merge` rule → any `merge` +context resolves to blocked. + +Packs are validated with `parseGitRepoPolicyPack` / `parseGitOrgPolicyPack` / +`parseGitPolicyPack`, all returning the shared `GitDeliveryParseResult`. + +## 5. Provider neutrality + +`git-delivery-provider.ts` describes provider state in neutral terms only: +`GitDeliveryBranchProtection`, `GitDeliveryChecksState`, `GitDeliveryPullRequestState` (with the +orthogonal `isDraft` boolean separate from the `open | closed | merged` status), +`GitDeliveryMergeReadiness`, `GitDeliveryRemoteTargetPolicy`, and `GitDeliveryProviderDescriptor`. + +**The rule:** no field name, value, or type from a specific provider's API may appear in these +interfaces. Provider adapters live in keiko-workflows or keiko-server — not in keiko-contracts, which +as a leaf package cannot import a provider SDK. The GitHub adapter is the reference implementation; +ADR-0058 §D6 carries the GitHub-to-neutral mapping table. Adding a second provider (GitLab, Gitea) +means writing another adapter that maps to the same neutral interfaces — no core contract changes. + +## 6. Terminal boundary guarantee + +The human-facing terminal allowlist (`isTerminalCommandAllowed`, keiko-tools) still permits only +read-only `git` inspection (`status`, `diff`, `log`, `show`, `rev-parse`, `ls-files`, `describe`, +`blame`, `cat-file`, and read-only `branch` / `remote` listings) and denies every mutating +subcommand. Governed write authority is reachable only through the typed contracts above, never by +widening that allowlist. + +This is machine-checked. The `AC5 — governed Git delivery boundary (ADR-0058)` block in +`packages/keiko-tools/src/terminal-policy.test.ts` asserts that the real mutating and network +commands each governed action maps to — `commit`, `push`, `push --force`, `merge`, `merge --abort`, +`branch `, `add .` / `add -A`, `restore --staged` / `reset HEAD`, `reset --hard` / `restore`, +`rebase`, `cherry-pick`, `revert`, `stash`, `clean`, `tag`, `switch -c`, `checkout -b`, `fetch`, +`pull` — all stay denied, while `git status` and `git log` stay allowed (a selective boundary, not +deny-everything). It also asserts that `GIT_DELIVERY_ACTION_KINDS` shares no member with any terminal +allowed subcommand, so the governed surface and the terminal allowlist are structurally disjoint. + +## 7. Extension model + +Later issues (#472 and beyond) add capabilities by extending these typed contracts, not by smuggling +shell semantics: + +- A new action kind adds a member to `GIT_DELIVERY_ACTION_KINDS`, a per-kind inputs interface, a + risk default, an envelope union member, and a resolved-input guard. +- A new constraint adds a member to `GitDeliveryConstraint` with its own guard (and matcher, if it + needs one) — never an embedded mini-language. +- A new provider capability adds a member to `GIT_DELIVERY_PROVIDER_CAPABILITIES`. +- A new provider adds an adapter that maps to the existing neutral interfaces. + +Policy packs are assembled from storage by keiko-server or keiko-workflows before +`evaluateGitPolicy` is called; the leaf evaluator does no IO. This separation is what keeps the +contract layer pure and the boundary auditable. diff --git a/packages/keiko-contracts/src/git-delivery-policy.test.ts b/packages/keiko-contracts/src/git-delivery-policy.test.ts new file mode 100644 index 000000000..a1985287b --- /dev/null +++ b/packages/keiko-contracts/src/git-delivery-policy.test.ts @@ -0,0 +1,354 @@ +// Behavioral tests for git-delivery-policy.ts (Issue #471, Epic #470). Covers every exported guard, +// every parser (ok + each distinct error path), and the full evaluateGitPolicy precedence matrix: +// org-blocked, repo-blocked, org-gated, repo-gated, constrained-union, allowed, fail-closed +// no-applicable-rule with empty packs, deny-by-default via defaultRule, and org-tighten-over-repo-loosen. + +import { describe, expect, it } from "vitest"; + +import { + GIT_DELIVERY_POLICY_SCHEMA_VERSION, + evaluateGitPolicy, + isGitDeliveryPolicyRule, + parseGitOrgPolicyPack, + parseGitPolicyPack, + parseGitRepoPolicyPack, +} from "./git-delivery-policy.js"; +import type { + GitDeliveryConstraint, + GitDeliveryOrgPolicyPack, + GitDeliveryPolicyContext, + GitDeliveryRepoPolicyPack, +} from "./index.js"; + +const CTX: GitDeliveryPolicyContext = { + actionKind: "push", + activeProviderCapabilities: [], +}; + +function orgPack(partial: Partial): GitDeliveryOrgPolicyPack { + return { + schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, + orgId: "org-1", + rules: [], + ...partial, + }; +} + +function repoPack(partial: Partial): GitDeliveryRepoPolicyPack { + return { + schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, + repoId: "repo-1", + rules: [], + ...partial, + }; +} + +describe("isGitDeliveryPolicyRule", () => { + it("accepts each well-formed decision shape", () => { + expect(isGitDeliveryPolicyRule({ actionKind: "push", decision: "allowed" })).toBe(true); + expect(isGitDeliveryPolicyRule({ actionKind: "push", decision: "blocked" })).toBe(true); + expect( + isGitDeliveryPolicyRule({ + actionKind: "push", + decision: "approval-gated", + requiredApprovers: ["lead"], + }), + ).toBe(true); + // approval-gated with no approvers list is structurally valid (means any one approver). + expect(isGitDeliveryPolicyRule({ actionKind: "push", decision: "approval-gated" })).toBe(true); + expect( + isGitDeliveryPolicyRule({ + actionKind: "merge", + decision: "constrained", + constraints: [{ kind: "provider-capability", capability: "required-checks" }], + }), + ).toBe(true); + }); + + it("rejects malformed rules", () => { + expect(isGitDeliveryPolicyRule({ actionKind: "", decision: "allowed" })).toBe(false); + expect(isGitDeliveryPolicyRule({ actionKind: "push", decision: "maybe" })).toBe(false); + expect( + isGitDeliveryPolicyRule({ + actionKind: "push", + decision: "approval-gated", + requiredApprovers: [1], + }), + ).toBe(false); + expect( + isGitDeliveryPolicyRule({ + actionKind: "merge", + decision: "constrained", + constraints: [{ kind: "min-risk-class" }], + }), + ).toBe(false); + expect(isGitDeliveryPolicyRule(null)).toBe(false); + }); +}); + +describe("evaluateGitPolicy precedence matrix", () => { + it("1. org-blocked dominates everything (even a repo allow)", () => { + const decision = evaluateGitPolicy( + orgPack({ rules: [{ actionKind: "push", decision: "blocked" }] }), + repoPack({ rules: [{ actionKind: "push", decision: "allowed" }] }), + CTX, + ); + expect(decision).toEqual({ outcome: "blocked", reason: "policy-pack-blocked" }); + }); + + it("1. repo-blocked also blocks (even when org allows)", () => { + const decision = evaluateGitPolicy( + orgPack({ rules: [{ actionKind: "push", decision: "allowed" }] }), + repoPack({ rules: [{ actionKind: "push", decision: "blocked" }] }), + CTX, + ); + expect(decision).toEqual({ outcome: "blocked", reason: "policy-pack-blocked" }); + }); + + it("2. org approval-gated wins over a repo loosen (org tighten dominates)", () => { + const decision = evaluateGitPolicy( + orgPack({ + rules: [ + { actionKind: "push", decision: "approval-gated", requiredApprovers: ["org-lead"] }, + ], + }), + repoPack({ rules: [{ actionKind: "push", decision: "allowed" }] }), + CTX, + ); + expect(decision).toEqual({ outcome: "approval-gated", requiredApprovers: ["org-lead"] }); + }); + + it("3. repo approval-gated applies when org is allowed/none", () => { + const decision = evaluateGitPolicy( + orgPack({ rules: [{ actionKind: "push", decision: "allowed" }] }), + repoPack({ + rules: [ + { actionKind: "push", decision: "approval-gated", requiredApprovers: ["repo-lead"] }, + ], + }), + CTX, + ); + expect(decision).toEqual({ outcome: "approval-gated", requiredApprovers: ["repo-lead"] }); + }); + + it("4. constrained unions org constraints first, then repo constraints", () => { + const orgConstraint: GitDeliveryConstraint = { + kind: "provider-capability", + capability: "branch-protection", + }; + const repoConstraint: GitDeliveryConstraint = { + kind: "risk-class-ceiling", + maxRiskClass: "publish", + }; + const decision = evaluateGitPolicy( + orgPack({ + rules: [{ actionKind: "push", decision: "constrained", constraints: [orgConstraint] }], + }), + repoPack({ + rules: [{ actionKind: "push", decision: "constrained", constraints: [repoConstraint] }], + }), + CTX, + ); + expect(decision).toEqual({ + outcome: "constrained", + constraints: [orgConstraint, repoConstraint], + }); + }); + + it("4. constrained on one level only still yields constrained", () => { + const repoConstraint: GitDeliveryConstraint = { + kind: "branch-pattern", + patterns: [{ matchKind: "exact", value: "main" }], + }; + const decision = evaluateGitPolicy( + orgPack({ rules: [{ actionKind: "push", decision: "allowed" }] }), + repoPack({ + rules: [{ actionKind: "push", decision: "constrained", constraints: [repoConstraint] }], + }), + CTX, + ); + expect(decision).toEqual({ outcome: "constrained", constraints: [repoConstraint] }); + }); + + it("5. allowed when either level allows and neither tightens", () => { + const decision = evaluateGitPolicy( + orgPack({ rules: [{ actionKind: "push", decision: "allowed" }] }), + repoPack({ rules: [] }), + CTX, + ); + expect(decision).toEqual({ outcome: "allowed" }); + }); + + it("6. fail-closed no-applicable-rule when both packs are empty", () => { + const decision = evaluateGitPolicy(orgPack({ rules: [] }), repoPack({ rules: [] }), CTX); + expect(decision).toEqual({ outcome: "blocked", reason: "no-applicable-rule" }); + }); + + it("6. fail-closed no-applicable-rule when both packs are undefined", () => { + const decision = evaluateGitPolicy(undefined, undefined, CTX); + expect(decision).toEqual({ outcome: "blocked", reason: "no-applicable-rule" }); + }); + + it("deny-by-default via org defaultRule blocks an unmatched kind", () => { + const decision = evaluateGitPolicy( + orgPack({ rules: [], defaultRule: { decision: "blocked" } }), + repoPack({ rules: [{ actionKind: "push", decision: "allowed" }] }), + CTX, + ); + expect(decision).toEqual({ outcome: "blocked", reason: "policy-pack-blocked" }); + }); + + it("repo defaultRule applies when no specific rule matches and org is none", () => { + const decision = evaluateGitPolicy( + undefined, + repoPack({ + rules: [], + defaultRule: { decision: "approval-gated", requiredApprovers: ["fallback"] }, + }), + CTX, + ); + expect(decision).toEqual({ outcome: "approval-gated", requiredApprovers: ["fallback"] }); + }); + + it("a specific rule overrides the defaultRule at the same level", () => { + const decision = evaluateGitPolicy( + undefined, + repoPack({ + rules: [{ actionKind: "push", decision: "allowed" }], + defaultRule: { decision: "blocked" }, + }), + CTX, + ); + expect(decision).toEqual({ outcome: "allowed" }); + }); + + it("empty approvers on an explicit approval-gated rule is NOT the fail-closed case", () => { + const decision = evaluateGitPolicy( + orgPack({ + rules: [{ actionKind: "push", decision: "approval-gated", requiredApprovers: [] }], + }), + undefined, + CTX, + ); + expect(decision).toEqual({ outcome: "approval-gated", requiredApprovers: [] }); + }); +}); + +describe("policy-pack parsers", () => { + it("parseGitRepoPolicyPack parses a valid pack with a defaultRule", () => { + const result = parseGitRepoPolicyPack({ + schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, + repoId: "repo-1", + rules: [{ actionKind: "push", decision: "blocked" }], + defaultRule: { decision: "blocked" }, + }); + expect(result.ok).toBe(true); + }); + + it("parseGitRepoPolicyPack rejects a non-object, bad schemaVersion, bad rules, missing repoId", () => { + expect(parseGitRepoPolicyPack(null).ok).toBe(false); + const badVersion = parseGitRepoPolicyPack({ + schemaVersion: "9", + repoId: "r", + rules: [], + }); + expect(badVersion.ok).toBe(false); + if (!badVersion.ok) { + expect(badVersion.errors.some((e) => e.includes("schemaVersion"))).toBe(true); + } + const badRules = parseGitRepoPolicyPack({ + schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, + repoId: "r", + rules: "nope", + }); + expect(badRules.ok).toBe(false); + if (!badRules.ok) { + expect(badRules.errors.some((e) => e.includes("rules must be an array"))).toBe(true); + } + const missingRepoId = parseGitRepoPolicyPack({ + schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, + rules: [], + }); + expect(missingRepoId.ok).toBe(false); + if (!missingRepoId.ok) { + expect(missingRepoId.errors.some((e) => e.includes("repoId"))).toBe(true); + } + }); + + it("parseGitRepoPolicyPack rejects an invalid rule entry", () => { + const result = parseGitRepoPolicyPack({ + schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, + repoId: "r", + rules: [{ actionKind: "push", decision: "maybe" }], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.includes("invalid GitDeliveryPolicyRule"))).toBe(true); + } + }); + + it("parseGitRepoPolicyPack rejects an invalid defaultRule", () => { + const result = parseGitRepoPolicyPack({ + schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, + repoId: "r", + rules: [], + defaultRule: { decision: "constrained", constraints: [{ kind: "bogus" }] }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.includes("defaultRule"))).toBe(true); + } + }); + + it("parseGitOrgPolicyPack parses a valid pack and rejects a missing orgId", () => { + expect( + parseGitOrgPolicyPack({ + schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, + orgId: "org-1", + rules: [], + }).ok, + ).toBe(true); + const missing = parseGitOrgPolicyPack({ + schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, + rules: [], + }); + expect(missing.ok).toBe(false); + if (!missing.ok) { + expect(missing.errors.some((e) => e.includes("orgId"))).toBe(true); + } + expect(parseGitOrgPolicyPack(7).ok).toBe(false); + }); + + it("parseGitPolicyPack discriminates repo vs org by id presence", () => { + const repo = parseGitPolicyPack({ + schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, + repoId: "r", + rules: [], + }); + expect(repo.ok).toBe(true); + if (repo.ok) { + expect("repoId" in repo.value).toBe(true); + } + const org = parseGitPolicyPack({ + schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, + orgId: "o", + rules: [], + }); + expect(org.ok).toBe(true); + if (org.ok) { + expect("orgId" in org.value).toBe(true); + } + }); + + it("parseGitPolicyPack rejects a pack with neither repoId nor orgId, and a non-object", () => { + const neither = parseGitPolicyPack({ + schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, + rules: [], + }); + expect(neither.ok).toBe(false); + if (!neither.ok) { + expect(neither.errors.some((e) => e.includes("repoId or orgId"))).toBe(true); + } + expect(parseGitPolicyPack("x").ok).toBe(false); + }); +}); diff --git a/packages/keiko-contracts/src/git-delivery-policy.ts b/packages/keiko-contracts/src/git-delivery-policy.ts new file mode 100644 index 000000000..afbe69c07 --- /dev/null +++ b/packages/keiko-contracts/src/git-delivery-policy.ts @@ -0,0 +1,295 @@ +// Policy-pack structures and deterministic pure evaluator for governed Git delivery +// (Issue #471, Epic #470). Ownership: git-delivery-policy domain. +// Leaf-package rules (ADR-0019): pure types and frozen const tables only. +// No IO, no clock, no randomness, no crypto. The evaluator is a pure function: +// same inputs always produce the same decision. Callers (keiko-server, keiko-workflows) +// assemble packs from storage BEFORE calling evaluateGitPolicy. +// +// Imports ONLY from ./git-delivery.js (action kinds, risk class, constraint union, policy decision, +// provider capability, parse result). No imports from git-delivery-provider.ts — one-directional DAG. + +import type { + GitDeliveryActionKind, + GitDeliveryConstraint, + GitDeliveryParseResult, + GitDeliveryPolicyDecision, + GitDeliveryProviderCapability, +} from "./git-delivery.js"; +import { isGitDeliveryConstraint, isGitDeliveryProviderCapability } from "./git-delivery.js"; + +export const GIT_DELIVERY_POLICY_SCHEMA_VERSION = "1" as const; + +// ─── Rule decision and rule ────────────────────────────────────────────────────── + +export type GitDeliveryRuleDecision = "allowed" | "blocked" | "approval-gated" | "constrained"; + +export const GIT_DELIVERY_RULE_DECISIONS: readonly GitDeliveryRuleDecision[] = [ + "allowed", + "blocked", + "approval-gated", + "constrained", +] as const; + +export interface GitDeliveryPolicyRule { + readonly actionKind: GitDeliveryActionKind; + readonly decision: GitDeliveryRuleDecision; + // Required when decision === "approval-gated". Opaque user/group IDs. An empty array means + // "at least one approver of any identity" — it is NOT the fail-closed case. + readonly requiredApprovers?: readonly string[] | undefined; + // Required when decision === "constrained". + readonly constraints?: readonly GitDeliveryConstraint[] | undefined; +} + +// Deny-by-default authorable as DATA. A pack can author { decision: "blocked" } so that any action +// kind without a specific rule is denied at that level. +export interface GitDeliveryDefaultRule { + readonly decision: GitDeliveryRuleDecision; + readonly requiredApprovers?: readonly string[] | undefined; + readonly constraints?: readonly GitDeliveryConstraint[] | undefined; +} + +// ─── Policy packs ──────────────────────────────────────────────────────────────── + +export interface GitDeliveryRepoPolicyPack { + readonly schemaVersion: typeof GIT_DELIVERY_POLICY_SCHEMA_VERSION; + readonly repoId: string; + readonly rules: readonly GitDeliveryPolicyRule[]; + readonly defaultRule?: GitDeliveryDefaultRule | undefined; +} + +export interface GitDeliveryOrgPolicyPack { + readonly schemaVersion: typeof GIT_DELIVERY_POLICY_SCHEMA_VERSION; + readonly orgId: string; + readonly rules: readonly GitDeliveryPolicyRule[]; + readonly defaultRule?: GitDeliveryDefaultRule | undefined; +} + +export interface GitDeliveryPolicyContext { + readonly actionKind: GitDeliveryActionKind; + readonly targetBranchName?: string | undefined; + readonly activeProviderCapabilities: readonly GitDeliveryProviderCapability[]; +} + +// ─── Deterministic pure evaluator ─────────────────────────────────────────────── +// Resolved per-level decision: one of allowed / blocked / approval-gated / constrained / none. +// `none` means neither a matching rule nor a defaultRule applied at that level. + +type ResolvedLevel = + | { readonly kind: "allowed" } + | { readonly kind: "blocked" } + | { readonly kind: "approval-gated"; readonly approvers: readonly string[] } + | { readonly kind: "constrained"; readonly constraints: readonly GitDeliveryConstraint[] } + | { readonly kind: "none" }; + +function resolveRuleToLevel( + rule: Pick, +): ResolvedLevel { + if (rule.decision === "allowed") { + return { kind: "allowed" }; + } + if (rule.decision === "blocked") { + return { kind: "blocked" }; + } + if (rule.decision === "approval-gated") { + return { kind: "approval-gated", approvers: rule.requiredApprovers ?? [] }; + } + return { kind: "constrained", constraints: rule.constraints ?? [] }; +} + +function resolveLevel( + pack: GitDeliveryRepoPolicyPack | GitDeliveryOrgPolicyPack | undefined, + context: GitDeliveryPolicyContext, +): ResolvedLevel { + if (pack === undefined) { + return { kind: "none" }; + } + const rule = pack.rules.find((candidate) => candidate.actionKind === context.actionKind); + if (rule !== undefined) { + return resolveRuleToLevel(rule); + } + if (pack.defaultRule !== undefined) { + return resolveRuleToLevel(pack.defaultRule); + } + return { kind: "none" }; +} + +function unionConstraints( + org: ResolvedLevel, + repo: ResolvedLevel, +): readonly GitDeliveryConstraint[] { + const orgConstraints = org.kind === "constrained" ? org.constraints : []; + const repoConstraints = repo.kind === "constrained" ? repo.constraints : []; + return [...orgConstraints, ...repoConstraints]; +} + +// Combine org (O) and repo (R) decisions by a total, deterministic precedence matrix (first match +// wins). Either level can tighten; org tightening dominates a repo loosen; both `none` fails closed. +function combineDecisions(org: ResolvedLevel, repo: ResolvedLevel): GitDeliveryPolicyDecision { + if (org.kind === "blocked" || repo.kind === "blocked") { + return { outcome: "blocked", reason: "policy-pack-blocked" }; + } + if (org.kind === "approval-gated") { + return { outcome: "approval-gated", requiredApprovers: org.approvers }; + } + if (repo.kind === "approval-gated") { + return { outcome: "approval-gated", requiredApprovers: repo.approvers }; + } + if (org.kind === "constrained" || repo.kind === "constrained") { + return { outcome: "constrained", constraints: unionConstraints(org, repo) }; + } + if (org.kind === "allowed" || repo.kind === "allowed") { + return { outcome: "allowed" }; + } + return { outcome: "blocked", reason: "no-applicable-rule" }; +} + +export function evaluateGitPolicy( + orgPack: GitDeliveryOrgPolicyPack | undefined, + repoPack: GitDeliveryRepoPolicyPack | undefined, + context: GitDeliveryPolicyContext, +): GitDeliveryPolicyDecision { + const org = resolveLevel(orgPack, context); + const repo = resolveLevel(repoPack, context); + return combineDecisions(org, repo); +} + +// ─── Private predicate helpers ─────────────────────────────────────────────────── + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} + +function isUndefinedOr(check: (v: unknown) => v is T): (v: unknown) => v is T | undefined { + return (v: unknown): v is T | undefined => v === undefined || check(v); +} + +function isConstraintArray(value: unknown): value is readonly GitDeliveryConstraint[] { + return Array.isArray(value) && value.every(isGitDeliveryConstraint); +} + +function isRuleDecision(value: unknown): value is GitDeliveryRuleDecision { + return ( + typeof value === "string" && (GIT_DELIVERY_RULE_DECISIONS as readonly string[]).includes(value) + ); +} + +// ─── Guards ────────────────────────────────────────────────────────────────────── + +export { isGitDeliveryConstraint, isGitDeliveryProviderCapability }; + +function isActionKind(value: unknown): boolean { + return isNonEmptyString(value); +} + +// A decision's per-decision required fields must validate. approval-gated requires a string-array +// (possibly empty); constrained requires a valid constraint array. allowed/blocked carry neither. +function decisionShapeValid(value: Record): boolean { + if (value.decision === "approval-gated") { + return isUndefinedOr(isStringArray)(value.requiredApprovers); + } + if (value.decision === "constrained") { + return isUndefinedOr(isConstraintArray)(value.constraints); + } + return true; +} + +export function isGitDeliveryPolicyRule(value: unknown): value is GitDeliveryPolicyRule { + return ( + isRecord(value) && + isActionKind(value.actionKind) && + isRuleDecision(value.decision) && + decisionShapeValid(value) + ); +} + +function isDefaultRule(value: unknown): value is GitDeliveryDefaultRule { + return isRecord(value) && isRuleDecision(value.decision) && decisionShapeValid(value); +} + +// ─── Parse functions (return the shared GitDeliveryParseResult) ────────────────── + +function ruleErrors(rules: unknown, field: "rules"): readonly string[] { + if (!Array.isArray(rules)) { + return [`pack.${field} must be an array`]; + } + if (!rules.every(isGitDeliveryPolicyRule)) { + return [`pack.${field} contains an invalid GitDeliveryPolicyRule`]; + } + return []; +} + +function defaultRuleErrors(defaultRule: unknown): readonly string[] { + if (defaultRule === undefined) { + return []; + } + if (!isDefaultRule(defaultRule)) { + return ["pack.defaultRule is not a valid GitDeliveryDefaultRule"]; + } + return []; +} + +function commonPackErrors(value: Record): readonly string[] { + const errors: string[] = []; + if (value.schemaVersion !== GIT_DELIVERY_POLICY_SCHEMA_VERSION) { + errors.push("pack.schemaVersion must equal the GIT_DELIVERY_POLICY_SCHEMA_VERSION literal"); + } + errors.push(...ruleErrors(value.rules, "rules")); + errors.push(...defaultRuleErrors(value.defaultRule)); + return errors; +} + +export function parseGitRepoPolicyPack( + value: unknown, +): GitDeliveryParseResult { + if (!isRecord(value)) { + return { ok: false, errors: ["repo policy pack must be an object"] }; + } + const errors = [...commonPackErrors(value)]; + if (!isNonEmptyString(value.repoId)) { + errors.push("repo policy pack.repoId must be a non-empty string"); + } + if (errors.length > 0) { + return { ok: false, errors }; + } + return { ok: true, value: value as unknown as GitDeliveryRepoPolicyPack }; +} + +export function parseGitOrgPolicyPack( + value: unknown, +): GitDeliveryParseResult { + if (!isRecord(value)) { + return { ok: false, errors: ["org policy pack must be an object"] }; + } + const errors = [...commonPackErrors(value)]; + if (!isNonEmptyString(value.orgId)) { + errors.push("org policy pack.orgId must be a non-empty string"); + } + if (errors.length > 0) { + return { ok: false, errors }; + } + return { ok: true, value: value as unknown as GitDeliveryOrgPolicyPack }; +} + +// Discriminates a pack by the presence of repoId vs orgId. +export function parseGitPolicyPack( + value: unknown, +): GitDeliveryParseResult { + if (!isRecord(value)) { + return { ok: false, errors: ["policy pack must be an object"] }; + } + if (isNonEmptyString(value.repoId)) { + return parseGitRepoPolicyPack(value); + } + if (isNonEmptyString(value.orgId)) { + return parseGitOrgPolicyPack(value); + } + return { ok: false, errors: ["policy pack must carry a non-empty repoId or orgId"] }; +} diff --git a/packages/keiko-contracts/src/git-delivery-provider.test.ts b/packages/keiko-contracts/src/git-delivery-provider.test.ts new file mode 100644 index 000000000..7ad972a66 --- /dev/null +++ b/packages/keiko-contracts/src/git-delivery-provider.test.ts @@ -0,0 +1,228 @@ +// Behavioral tests for git-delivery-provider.ts (Issue #471, Epic #470). Covers every exported +// guard (positive AND negative cases) for the provider-neutral interfaces. + +import { describe, expect, it } from "vitest"; + +import { + GIT_DELIVERY_PROVIDER_SCHEMA_VERSION, + isGitDeliveryBranchProtection, + isGitDeliveryChecksOverallStatus, + isGitDeliveryChecksState, + isGitDeliveryMergeReadiness, + isGitDeliveryProviderDescriptor, + isGitDeliveryPullRequestState, + isGitDeliveryPullRequestStatus, +} from "./git-delivery-provider.js"; + +describe("provider status guards", () => { + it("isGitDeliveryChecksOverallStatus discriminates the four statuses", () => { + expect(isGitDeliveryChecksOverallStatus("passing")).toBe(true); + expect(isGitDeliveryChecksOverallStatus("skipped")).toBe(true); + expect(isGitDeliveryChecksOverallStatus("flaky")).toBe(false); + expect(isGitDeliveryChecksOverallStatus(3)).toBe(false); + }); + + it("isGitDeliveryPullRequestStatus excludes the orthogonal draft state", () => { + expect(isGitDeliveryPullRequestStatus("open")).toBe(true); + expect(isGitDeliveryPullRequestStatus("closed")).toBe(true); + expect(isGitDeliveryPullRequestStatus("merged")).toBe(true); + // draft is orthogonal (isDraft boolean), not a lifecycle status. + expect(isGitDeliveryPullRequestStatus("draft")).toBe(false); + }); +}); + +describe("isGitDeliveryBranchProtection", () => { + it("accepts a well-formed protection record", () => { + expect( + isGitDeliveryBranchProtection({ + deletionAllowed: false, + forcePushAllowed: false, + linearHistoryRequired: true, + requiredReviewCount: 2, + requiredStatusCheckCount: 3, + }), + ).toBe(true); + }); + + it("rejects non-boolean flags and negative/fractional counts", () => { + expect( + isGitDeliveryBranchProtection({ + deletionAllowed: "no", + forcePushAllowed: false, + linearHistoryRequired: true, + requiredReviewCount: 2, + requiredStatusCheckCount: 3, + }), + ).toBe(false); + expect( + isGitDeliveryBranchProtection({ + deletionAllowed: false, + forcePushAllowed: false, + linearHistoryRequired: true, + requiredReviewCount: -1, + requiredStatusCheckCount: 3, + }), + ).toBe(false); + expect( + isGitDeliveryBranchProtection({ + deletionAllowed: false, + forcePushAllowed: false, + linearHistoryRequired: true, + requiredReviewCount: 1.5, + requiredStatusCheckCount: 3, + }), + ).toBe(false); + expect(isGitDeliveryBranchProtection(null)).toBe(false); + }); +}); + +describe("isGitDeliveryChecksState", () => { + it("accepts a well-formed checks state", () => { + expect( + isGitDeliveryChecksState({ + total: 4, + passing: 3, + failing: 0, + pending: 1, + overallStatus: "pending", + }), + ).toBe(true); + }); + + it("rejects a bad overallStatus or non-integer counts", () => { + expect( + isGitDeliveryChecksState({ + total: 4, + passing: 3, + failing: 0, + pending: 1, + overallStatus: "weird", + }), + ).toBe(false); + expect( + isGitDeliveryChecksState({ + total: "4", + passing: 3, + failing: 0, + pending: 1, + overallStatus: "passing", + }), + ).toBe(false); + }); +}); + +describe("isGitDeliveryMergeReadiness", () => { + it("accepts ready with and without a typed blocking reason", () => { + expect( + isGitDeliveryMergeReadiness({ + ready: true, + requiredApprovalCount: 2, + receivedApprovalCount: 2, + }), + ).toBe(true); + expect( + isGitDeliveryMergeReadiness({ + ready: false, + blockingReason: "checks-failing", + requiredApprovalCount: 2, + receivedApprovalCount: 0, + }), + ).toBe(true); + }); + + it("rejects a free-form blocking reason and bad counts", () => { + expect( + isGitDeliveryMergeReadiness({ + ready: false, + blockingReason: "because", + requiredApprovalCount: 2, + receivedApprovalCount: 0, + }), + ).toBe(false); + expect( + isGitDeliveryMergeReadiness({ + ready: true, + requiredApprovalCount: -1, + receivedApprovalCount: 0, + }), + ).toBe(false); + expect(isGitDeliveryMergeReadiness(undefined)).toBe(false); + }); +}); + +describe("isGitDeliveryPullRequestState", () => { + function validState(): Record { + return { + schemaVersion: GIT_DELIVERY_PROVIDER_SCHEMA_VERSION, + externalId: "42", + status: "open", + isDraft: false, + headBranchName: "feature/x", + baseBranchName: "main", + mergeReadiness: { + ready: true, + requiredApprovalCount: 1, + receivedApprovalCount: 1, + }, + }; + } + + it("accepts a well-formed PR state with orthogonal isDraft", () => { + expect(isGitDeliveryPullRequestState(validState())).toBe(true); + expect(isGitDeliveryPullRequestState({ ...validState(), isDraft: true })).toBe(true); + }); + + it("rejects a bad schemaVersion, missing isDraft, and an invalid nested mergeReadiness", () => { + expect(isGitDeliveryPullRequestState({ ...validState(), schemaVersion: "2" })).toBe(false); + const withoutDraft: Record = { ...validState() }; + delete withoutDraft.isDraft; + expect(isGitDeliveryPullRequestState(withoutDraft)).toBe(false); + expect( + isGitDeliveryPullRequestState({ ...validState(), mergeReadiness: { ready: "yes" } }), + ).toBe(false); + expect(isGitDeliveryPullRequestState(null)).toBe(false); + }); +}); + +describe("isGitDeliveryProviderDescriptor", () => { + it("accepts a descriptor whose capabilities are non-empty strings", () => { + expect( + isGitDeliveryProviderDescriptor({ + schemaVersion: GIT_DELIVERY_PROVIDER_SCHEMA_VERSION, + providerInstanceId: "inst-1", + capabilities: ["branch-protection", "merge-queue"], + }), + ).toBe(true); + expect( + isGitDeliveryProviderDescriptor({ + schemaVersion: GIT_DELIVERY_PROVIDER_SCHEMA_VERSION, + providerInstanceId: "inst-1", + capabilities: [], + }), + ).toBe(true); + }); + + it("rejects a bad schemaVersion, empty instance id, and non-string capabilities", () => { + expect( + isGitDeliveryProviderDescriptor({ + schemaVersion: "2", + providerInstanceId: "inst-1", + capabilities: [], + }), + ).toBe(false); + expect( + isGitDeliveryProviderDescriptor({ + schemaVersion: GIT_DELIVERY_PROVIDER_SCHEMA_VERSION, + providerInstanceId: "", + capabilities: [], + }), + ).toBe(false); + expect( + isGitDeliveryProviderDescriptor({ + schemaVersion: GIT_DELIVERY_PROVIDER_SCHEMA_VERSION, + providerInstanceId: "inst-1", + capabilities: [7], + }), + ).toBe(false); + }); +}); diff --git a/packages/keiko-contracts/src/git-delivery-provider.ts b/packages/keiko-contracts/src/git-delivery-provider.ts new file mode 100644 index 000000000..88b3ce02e --- /dev/null +++ b/packages/keiko-contracts/src/git-delivery-provider.ts @@ -0,0 +1,200 @@ +// Provider-neutral interfaces for governed Git delivery (Issue #471, Epic #470). +// Ownership: git-delivery-provider domain. +// THE RULE: no field name, value, or type from any specific provider's API documentation +// may appear in this file. Provider adapters (in keiko-workflows or keiko-server) map +// provider responses to these neutral interfaces. This is enforced by code review and by +// the leaf-package boundary (ADR-0019 rule 1): keiko-contracts cannot import a provider SDK. +// Leaf-package rules: pure types and frozen const tables only. No IO. +// +// Imports ONLY from ./git-delivery.js (GitDeliveryMergeBlockReason, GitDeliveryProviderCapability). + +import type { GitDeliveryMergeBlockReason, GitDeliveryProviderCapability } from "./git-delivery.js"; +import { isGitDeliveryMergeBlockReason } from "./git-delivery.js"; + +export const GIT_DELIVERY_PROVIDER_SCHEMA_VERSION = "1" as const; + +// ─── Branch protection ─────────────────────────────────────────────────────────── +// Presence of this interface on a branch descriptor indicates the branch has at least one +// protection rule active; absence means unprotected. + +export interface GitDeliveryBranchProtection { + readonly deletionAllowed: boolean; + readonly forcePushAllowed: boolean; + readonly linearHistoryRequired: boolean; + readonly requiredReviewCount: number; + // Count of required status checks (not names — provider-specific check names stay in adapters). + readonly requiredStatusCheckCount: number; +} + +// ─── Checks ────────────────────────────────────────────────────────────────────── + +export type GitDeliveryChecksOverallStatus = "passing" | "failing" | "pending" | "skipped"; + +export const GIT_DELIVERY_CHECKS_OVERALL_STATUSES: readonly GitDeliveryChecksOverallStatus[] = [ + "passing", + "failing", + "pending", + "skipped", +] as const; + +// Aggregate check state. No check names, no provider run IDs. +export interface GitDeliveryChecksState { + readonly total: number; + readonly passing: number; + readonly failing: number; + readonly pending: number; + readonly overallStatus: GitDeliveryChecksOverallStatus; +} + +// ─── Pull request ──────────────────────────────────────────────────────────────── +// Draftness is orthogonal to lifecycle status, so it is a separate boolean (not a status member). + +export type GitDeliveryPullRequestStatus = "open" | "closed" | "merged"; + +export const GIT_DELIVERY_PULL_REQUEST_STATUSES: readonly GitDeliveryPullRequestStatus[] = [ + "open", + "closed", + "merged", +] as const; + +export interface GitDeliveryMergeReadiness { + readonly ready: boolean; + readonly blockingReason?: GitDeliveryMergeBlockReason | undefined; + readonly requiredApprovalCount: number; + readonly receivedApprovalCount: number; +} + +export interface GitDeliveryPullRequestState { + readonly schemaVersion: typeof GIT_DELIVERY_PROVIDER_SCHEMA_VERSION; + readonly externalId: string; // opaque provider-assigned ID + readonly status: GitDeliveryPullRequestStatus; + readonly isDraft: boolean; + readonly headBranchName: string; + readonly baseBranchName: string; + readonly mergeReadiness: GitDeliveryMergeReadiness; +} + +// ─── Remote target policy and provider descriptor ──────────────────────────────── + +export interface GitDeliveryRemoteTargetPolicy { + // Branch name patterns the remote accepts as push targets. Empty array means all branches are + // accepted (no restriction). + readonly allowedPushTargetPatterns: readonly string[]; + readonly forcePushGloballyDenied: boolean; +} + +// Provider capability descriptor. Populated by the adapter from the provider's +// capability-advertisement endpoint or static configuration. +export interface GitDeliveryProviderDescriptor { + readonly schemaVersion: typeof GIT_DELIVERY_PROVIDER_SCHEMA_VERSION; + // Opaque identifier for the provider instance (not a URL — avoid leaking endpoints). + readonly providerInstanceId: string; + readonly capabilities: readonly GitDeliveryProviderCapability[]; +} + +// ─── Private predicate helpers ─────────────────────────────────────────────────── + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isBoolean(value: unknown): value is boolean { + return typeof value === "boolean"; +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +function isUndefinedOr(check: (v: unknown) => v is T): (v: unknown) => v is T | undefined { + return (v: unknown): v is T | undefined => v === undefined || check(v); +} + +function isProviderCapability(value: unknown): boolean { + return isNonEmptyString(value); +} + +// ─── Guards ────────────────────────────────────────────────────────────────────── + +export function isGitDeliveryChecksOverallStatus( + value: unknown, +): value is GitDeliveryChecksOverallStatus { + return ( + isNonEmptyString(value) && + (GIT_DELIVERY_CHECKS_OVERALL_STATUSES as readonly string[]).includes(value) + ); +} + +export function isGitDeliveryPullRequestStatus( + value: unknown, +): value is GitDeliveryPullRequestStatus { + return ( + isNonEmptyString(value) && + (GIT_DELIVERY_PULL_REQUEST_STATUSES as readonly string[]).includes(value) + ); +} + +export function isGitDeliveryBranchProtection( + value: unknown, +): value is GitDeliveryBranchProtection { + return ( + isRecord(value) && + isBoolean(value.deletionAllowed) && + isBoolean(value.forcePushAllowed) && + isBoolean(value.linearHistoryRequired) && + isNonNegativeInteger(value.requiredReviewCount) && + isNonNegativeInteger(value.requiredStatusCheckCount) + ); +} + +export function isGitDeliveryChecksState(value: unknown): value is GitDeliveryChecksState { + return ( + isRecord(value) && + isNonNegativeInteger(value.total) && + isNonNegativeInteger(value.passing) && + isNonNegativeInteger(value.failing) && + isNonNegativeInteger(value.pending) && + isGitDeliveryChecksOverallStatus(value.overallStatus) + ); +} + +export function isGitDeliveryMergeReadiness(value: unknown): value is GitDeliveryMergeReadiness { + return ( + isRecord(value) && + isBoolean(value.ready) && + isUndefinedOr(isGitDeliveryMergeBlockReason)(value.blockingReason) && + isNonNegativeInteger(value.requiredApprovalCount) && + isNonNegativeInteger(value.receivedApprovalCount) + ); +} + +export function isGitDeliveryPullRequestState( + value: unknown, +): value is GitDeliveryPullRequestState { + return ( + isRecord(value) && + value.schemaVersion === GIT_DELIVERY_PROVIDER_SCHEMA_VERSION && + isNonEmptyString(value.externalId) && + isGitDeliveryPullRequestStatus(value.status) && + isBoolean(value.isDraft) && + isNonEmptyString(value.headBranchName) && + isNonEmptyString(value.baseBranchName) && + isGitDeliveryMergeReadiness(value.mergeReadiness) + ); +} + +export function isGitDeliveryProviderDescriptor( + value: unknown, +): value is GitDeliveryProviderDescriptor { + return ( + isRecord(value) && + value.schemaVersion === GIT_DELIVERY_PROVIDER_SCHEMA_VERSION && + isNonEmptyString(value.providerInstanceId) && + Array.isArray(value.capabilities) && + value.capabilities.every(isProviderCapability) + ); +} diff --git a/packages/keiko-contracts/src/git-delivery.test.ts b/packages/keiko-contracts/src/git-delivery.test.ts new file mode 100644 index 000000000..cd85576a6 --- /dev/null +++ b/packages/keiko-contracts/src/git-delivery.test.ts @@ -0,0 +1,545 @@ +// Behavioral tests for git-delivery.ts (Issue #471, Epic #470). Covers every exported guard +// (positive AND negative), every parser (ok path + each distinct error path), the risk-class +// classifiers (incl. fail-closed and force-push escalation), the typed branch matchers, the +// risk-class ceiling, and the envelope soundness invariant (kind === resolvedInputs.kind). + +import { describe, expect, it } from "vitest"; + +import { + GIT_DELIVERY_ACTION_RISK_DEFAULTS, + GIT_DELIVERY_RISK_CLASS_SEVERITY, + GIT_DELIVERY_SCHEMA_VERSION, + gitDeliveryBranchNameMatchesAny, + gitDeliveryBranchNameMatchesPattern, + gitDeliveryDefaultRiskClass, + gitDeliveryRiskClassForInputs, + gitDeliveryRiskClassWithinCeiling, + isGitDeliveryAbortableOperation, + isGitDeliveryActionKind, + isGitDeliveryApprovalRequirement, + isGitDeliveryBlockReason, + isGitDeliveryBranchMatchKind, + isGitDeliveryBranchPattern, + isGitDeliveryConstraint, + isGitDeliveryEvidenceRef, + isGitDeliveryExecutionErrorCode, + isGitDeliveryExecutionOutcome, + isGitDeliveryExecutionResult, + isGitDeliveryMergeBlockReason, + isGitDeliveryMergeStrategyHint, + isGitDeliveryPolicyDecision, + isGitDeliveryProviderCapability, + isGitDeliveryRecoveryStrategyHint, + isGitDeliveryRiskClass, + parseGitDeliveryActionEnvelope, + parseGitDeliveryResolvedInputs, +} from "./git-delivery.js"; +import type { + GitDeliveryBranchPattern, + GitDeliveryPushInputs, + GitDeliveryResolvedInputs, +} from "./git-delivery.js"; + +// A 64-char lowercase hex string for the approval-token-shape check. +const TOKEN_HASH = "a".repeat(64); + +describe("git-delivery action-kind / risk-class guards", () => { + it("isGitDeliveryActionKind accepts known kinds and rejects unknowns", () => { + expect(isGitDeliveryActionKind("commit")).toBe(true); + expect(isGitDeliveryActionKind("recovery")).toBe(true); + expect(isGitDeliveryActionKind("rm")).toBe(false); + expect(isGitDeliveryActionKind(42)).toBe(false); + expect(isGitDeliveryActionKind(undefined)).toBe(false); + }); + + it("isGitDeliveryRiskClass discriminates the four classes", () => { + expect(isGitDeliveryRiskClass("local-mutation")).toBe(true); + expect(isGitDeliveryRiskClass("recovery-or-rewrite")).toBe(true); + expect(isGitDeliveryRiskClass("nuclear")).toBe(false); + expect(isGitDeliveryRiskClass(null)).toBe(false); + }); + + it("isGitDeliveryProviderCapability guards the five capabilities", () => { + expect(isGitDeliveryProviderCapability("merge-queue")).toBe(true); + expect(isGitDeliveryProviderCapability("warp-drive")).toBe(false); + }); + + it("isGitDeliveryBranchMatchKind guards exact/prefix only", () => { + expect(isGitDeliveryBranchMatchKind("exact")).toBe(true); + expect(isGitDeliveryBranchMatchKind("prefix")).toBe(true); + expect(isGitDeliveryBranchMatchKind("glob")).toBe(false); + }); + + it("isGitDeliveryBlockReason includes the fail-closed no-applicable-rule", () => { + expect(isGitDeliveryBlockReason("no-applicable-rule")).toBe(true); + expect(isGitDeliveryBlockReason("policy-pack-blocked")).toBe(true); + expect(isGitDeliveryBlockReason("vibes")).toBe(false); + }); + + it("isGitDeliveryMergeBlockReason guards the six merge block reasons", () => { + expect(isGitDeliveryMergeBlockReason("checks-failing")).toBe(true); + expect(isGitDeliveryMergeBlockReason("no-applicable-rule")).toBe(false); + }); + + it("isGitDeliveryMergeStrategyHint / AbortableOperation / RecoveryStrategyHint guards", () => { + expect(isGitDeliveryMergeStrategyHint("squash")).toBe(true); + expect(isGitDeliveryMergeStrategyHint("fast-forward")).toBe(false); + expect(isGitDeliveryAbortableOperation("bisect")).toBe(true); + expect(isGitDeliveryAbortableOperation("commit")).toBe(false); + expect(isGitDeliveryRecoveryStrategyHint("stash-and-reset")).toBe(true); + expect(isGitDeliveryRecoveryStrategyHint("hard-reset")).toBe(false); + }); + + it("isGitDeliveryExecutionOutcome / ExecutionErrorCode guards", () => { + expect(isGitDeliveryExecutionOutcome("partial")).toBe(true); + expect(isGitDeliveryExecutionOutcome("ok")).toBe(false); + expect(isGitDeliveryExecutionErrorCode("timeout")).toBe(true); + expect(isGitDeliveryExecutionErrorCode("boom")).toBe(false); + }); +}); + +describe("git-delivery branch-pattern and constraint guards", () => { + it("isGitDeliveryBranchPattern requires matchKind and a non-empty value", () => { + expect(isGitDeliveryBranchPattern({ matchKind: "exact", value: "main" })).toBe(true); + expect(isGitDeliveryBranchPattern({ matchKind: "prefix", value: "release/" })).toBe(true); + expect(isGitDeliveryBranchPattern({ matchKind: "glob", value: "main" })).toBe(false); + expect(isGitDeliveryBranchPattern({ matchKind: "exact", value: "" })).toBe(false); + expect(isGitDeliveryBranchPattern(null)).toBe(false); + }); + + it("isGitDeliveryConstraint discriminates all three constraint kinds", () => { + expect( + isGitDeliveryConstraint({ + kind: "branch-pattern", + patterns: [{ matchKind: "exact", value: "main" }], + }), + ).toBe(true); + expect( + isGitDeliveryConstraint({ kind: "provider-capability", capability: "required-checks" }), + ).toBe(true); + expect(isGitDeliveryConstraint({ kind: "risk-class-ceiling", maxRiskClass: "publish" })).toBe( + true, + ); + }); + + it("isGitDeliveryConstraint rejects malformed and unknown-kind constraints", () => { + expect(isGitDeliveryConstraint({ kind: "min-risk-class", riskClass: "publish" })).toBe(false); + expect(isGitDeliveryConstraint({ kind: "branch-pattern", patterns: ["main"] })).toBe(false); + expect(isGitDeliveryConstraint({ kind: "provider-capability", capability: "nope" })).toBe( + false, + ); + expect(isGitDeliveryConstraint({ kind: "risk-class-ceiling", maxRiskClass: "nope" })).toBe( + false, + ); + expect(isGitDeliveryConstraint(42)).toBe(false); + }); +}); + +describe("git-delivery approval / policy-decision / evidence / execution-result guards", () => { + it("isGitDeliveryApprovalRequirement accepts both discriminants", () => { + expect(isGitDeliveryApprovalRequirement({ required: false })).toBe(true); + expect( + isGitDeliveryApprovalRequirement({ + required: true, + approvalTokenHash: TOKEN_HASH, + approvedByUserId: "u-1", + approvedAtMs: 1, + }), + ).toBe(true); + expect( + isGitDeliveryApprovalRequirement({ + required: true, + approvalTokenHash: TOKEN_HASH, + approvedByUserId: "u-1", + approvedAtMs: 1, + expiresAtMs: 99, + }), + ).toBe(true); + }); + + it("isGitDeliveryApprovalRequirement rejects a malformed token hash and missing fields", () => { + expect( + isGitDeliveryApprovalRequirement({ + required: true, + approvalTokenHash: "short", + approvedByUserId: "u-1", + approvedAtMs: 1, + }), + ).toBe(false); + expect( + isGitDeliveryApprovalRequirement({ + required: true, + approvalTokenHash: TOKEN_HASH, + approvedByUserId: "", + approvedAtMs: 1, + }), + ).toBe(false); + expect(isGitDeliveryApprovalRequirement({ required: "yes" })).toBe(false); + expect(isGitDeliveryApprovalRequirement(null)).toBe(false); + }); + + it("isGitDeliveryPolicyDecision validates every outcome and rejects unknowns", () => { + expect(isGitDeliveryPolicyDecision({ outcome: "allowed" })).toBe(true); + expect(isGitDeliveryPolicyDecision({ outcome: "blocked", reason: "protected-branch" })).toBe( + true, + ); + expect( + isGitDeliveryPolicyDecision({ outcome: "approval-gated", requiredApprovers: ["lead"] }), + ).toBe(true); + expect( + isGitDeliveryPolicyDecision({ + outcome: "constrained", + constraints: [{ kind: "provider-capability", capability: "merge-queue" }], + }), + ).toBe(true); + expect(isGitDeliveryPolicyDecision({ outcome: "blocked", reason: "made-up" })).toBe(false); + expect(isGitDeliveryPolicyDecision({ outcome: "approval-gated", requiredApprovers: [1] })).toBe( + false, + ); + expect( + isGitDeliveryPolicyDecision({ outcome: "constrained", constraints: [{ kind: "x" }] }), + ).toBe(false); + expect(isGitDeliveryPolicyDecision({ outcome: "frozen" })).toBe(false); + }); + + it("isGitDeliveryEvidenceRef requires both opaque references", () => { + expect( + isGitDeliveryEvidenceRef({ + sourceGroundedRunId: "run-1", + evidenceManifestStableIdHash: "deadbeef", + }), + ).toBe(true); + expect(isGitDeliveryEvidenceRef({ sourceGroundedRunId: "run-1" })).toBe(false); + expect( + isGitDeliveryEvidenceRef({ sourceGroundedRunId: "", evidenceManifestStableIdHash: "x" }), + ).toBe(false); + }); + + it("isGitDeliveryExecutionResult validates outcome, errorCode, and partialDetail", () => { + expect( + isGitDeliveryExecutionResult({ + schemaVersion: GIT_DELIVERY_SCHEMA_VERSION, + outcome: "succeeded", + durationMs: 5, + }), + ).toBe(true); + expect( + isGitDeliveryExecutionResult({ + schemaVersion: GIT_DELIVERY_SCHEMA_VERSION, + outcome: "partial", + durationMs: 5, + errorCode: "conflict", + partialDetail: { attemptedUnitCount: 3, succeededUnitCount: 1 }, + }), + ).toBe(true); + expect( + isGitDeliveryExecutionResult({ + schemaVersion: GIT_DELIVERY_SCHEMA_VERSION, + outcome: "failed", + durationMs: 5, + errorCode: "free-form-string", + }), + ).toBe(false); + expect( + isGitDeliveryExecutionResult({ + schemaVersion: "2", + outcome: "succeeded", + durationMs: 5, + }), + ).toBe(false); + expect( + isGitDeliveryExecutionResult({ + schemaVersion: GIT_DELIVERY_SCHEMA_VERSION, + outcome: "partial", + durationMs: 5, + partialDetail: { attemptedUnitCount: -1, succeededUnitCount: 1 }, + }), + ).toBe(false); + }); +}); + +describe("parseGitDeliveryResolvedInputs", () => { + it("parses every action kind on the ok path", () => { + const cases: readonly Record[] = [ + { kind: "branch-create", branchName: "f", baseBranchName: "main", startPointRefHash: "h" }, + { kind: "stage", pathCount: 2, includesUntracked: false }, + { kind: "unstage", pathCount: 1 }, + { kind: "commit", messageByteLength: 10, stagedPathCount: 1, allowEmptyCommit: false }, + { + kind: "push", + sourceBranchName: "f", + remoteAlias: "origin", + remoteBranchName: "f", + forcePush: false, + setUpstreamTracking: true, + }, + { + kind: "pr-create", + headBranchName: "f", + baseBranchName: "main", + titleByteLength: 5, + bodyByteLength: 9, + isDraft: true, + }, + { + kind: "pr-update", + prExternalId: "42", + headBranchName: "f", + baseBranchName: "main", + titleByteLength: 5, + bodyByteLength: 9, + convertToDraft: false, + convertFromDraft: true, + }, + { + kind: "merge", + prExternalId: "42", + mergeStrategyHint: "squash", + deleteBranchAfterMerge: true, + }, + { kind: "abort", operationToAbort: "rebase", preserveIndexChanges: false }, + { + kind: "recovery", + recoveryStrategyHint: "soft-reset", + targetRefHash: "h", + affectedPathCount: 3, + }, + ]; + for (const value of cases) { + const result = parseGitDeliveryResolvedInputs(value); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.kind).toBe(value.kind); + } + } + }); + + it("rejects a non-object", () => { + const result = parseGitDeliveryResolvedInputs("nope"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors[0]).toContain("must be an object"); + } + }); + + it("rejects an unknown kind", () => { + const result = parseGitDeliveryResolvedInputs({ kind: "force-push" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors[0]).toContain("known GitDeliveryActionKind"); + } + }); + + it("rejects a known kind with invalid fields", () => { + const result = parseGitDeliveryResolvedInputs({ kind: "stage", pathCount: -1 }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors[0]).toContain('kind "stage"'); + } + }); +}); + +describe("parseGitDeliveryActionEnvelope (soundness: kind === resolvedInputs.kind)", () => { + const validPushInputs: GitDeliveryPushInputs = { + kind: "push", + sourceBranchName: "f", + remoteAlias: "origin", + remoteBranchName: "f", + forcePush: false, + setUpstreamTracking: true, + }; + + function baseEnvelope(): Record { + return { + schemaVersion: GIT_DELIVERY_SCHEMA_VERSION, + actionId: "act-1", + kind: "push", + resolvedInputs: validPushInputs, + policyDecision: { outcome: "allowed" }, + approvalRequirement: { required: false }, + }; + } + + it("parses a sound envelope on the ok path", () => { + const result = parseGitDeliveryActionEnvelope(baseEnvelope()); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.kind).toBe("push"); + } + }); + + it("parses an envelope with optional preview / executionResult / evidenceRef", () => { + const result = parseGitDeliveryActionEnvelope({ + ...baseEnvelope(), + preview: { + schemaVersion: GIT_DELIVERY_SCHEMA_VERSION, + wouldCreateRemoteBranch: true, + wouldTriggerChecks: false, + }, + executionResult: { + schemaVersion: GIT_DELIVERY_SCHEMA_VERSION, + outcome: "succeeded", + durationMs: 3, + }, + evidenceRef: { sourceGroundedRunId: "run-1", evidenceManifestStableIdHash: "h" }, + }); + expect(result.ok).toBe(true); + }); + + it("rejects a non-object", () => { + const result = parseGitDeliveryActionEnvelope(7); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors[0]).toContain("envelope must be an object"); + } + }); + + it("propagates resolvedInputs parse errors", () => { + const result = parseGitDeliveryActionEnvelope({ + ...baseEnvelope(), + resolvedInputs: { kind: "push" }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors[0]).toContain('kind "push"'); + } + }); + + it("rejects a kind that disagrees with resolvedInputs.kind", () => { + const result = parseGitDeliveryActionEnvelope({ ...baseEnvelope(), kind: "commit" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors[0]).toContain("must equal envelope.resolvedInputs.kind"); + } + }); + + it("rejects a wrong schemaVersion", () => { + const result = parseGitDeliveryActionEnvelope({ ...baseEnvelope(), schemaVersion: "2" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.includes("schemaVersion"))).toBe(true); + } + }); + + it("rejects an empty actionId", () => { + const result = parseGitDeliveryActionEnvelope({ ...baseEnvelope(), actionId: "" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.includes("actionId"))).toBe(true); + } + }); + + it("rejects an invalid policyDecision", () => { + const result = parseGitDeliveryActionEnvelope({ + ...baseEnvelope(), + policyDecision: { outcome: "frozen" }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.includes("policyDecision"))).toBe(true); + } + }); + + it("rejects an invalid approvalRequirement", () => { + const result = parseGitDeliveryActionEnvelope({ + ...baseEnvelope(), + approvalRequirement: { required: true }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.includes("approvalRequirement"))).toBe(true); + } + }); + + it("rejects an invalid optional preview", () => { + const result = parseGitDeliveryActionEnvelope({ + ...baseEnvelope(), + preview: { schemaVersion: GIT_DELIVERY_SCHEMA_VERSION, wouldCreateRemoteBranch: "yes" }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.includes("preview"))).toBe(true); + } + }); +}); + +describe("risk-class classifiers", () => { + it("gitDeliveryDefaultRiskClass returns the per-kind default for every known kind", () => { + for (const kind of Object.keys(GIT_DELIVERY_ACTION_RISK_DEFAULTS)) { + expect(gitDeliveryDefaultRiskClass(kind)).toBe( + GIT_DELIVERY_ACTION_RISK_DEFAULTS[kind as keyof typeof GIT_DELIVERY_ACTION_RISK_DEFAULTS], + ); + } + }); + + it("gitDeliveryDefaultRiskClass fails closed to recovery-or-rewrite for unknown kinds", () => { + expect(gitDeliveryDefaultRiskClass("teleport")).toBe("recovery-or-rewrite"); + expect(gitDeliveryDefaultRiskClass("")).toBe("recovery-or-rewrite"); + }); + + it("gitDeliveryRiskClassForInputs escalates a force-push above its default publish class", () => { + const force: GitDeliveryResolvedInputs = { + kind: "push", + sourceBranchName: "f", + remoteAlias: "origin", + remoteBranchName: "f", + forcePush: true, + setUpstreamTracking: false, + }; + const normal: GitDeliveryResolvedInputs = { ...force, forcePush: false }; + expect(gitDeliveryRiskClassForInputs(force)).toBe("recovery-or-rewrite"); + expect(gitDeliveryRiskClassForInputs(normal)).toBe("publish"); + }); + + it("gitDeliveryRiskClassForInputs defers to the default for non-push kinds", () => { + const commit: GitDeliveryResolvedInputs = { + kind: "commit", + messageByteLength: 1, + stagedPathCount: 1, + allowEmptyCommit: false, + }; + expect(gitDeliveryRiskClassForInputs(commit)).toBe("local-mutation"); + }); + + it("gitDeliveryRiskClassWithinCeiling compares severities via the frozen table", () => { + // commit (local-mutation, 1) within a publish ceiling (2). + expect(gitDeliveryRiskClassWithinCeiling("commit", "publish")).toBe(true); + // commit within its own class (equal severity). + expect(gitDeliveryRiskClassWithinCeiling("commit", "local-mutation")).toBe(true); + // merge (protected-or-merge, 3) exceeds a publish ceiling (2). + expect(gitDeliveryRiskClassWithinCeiling("merge", "publish")).toBe(false); + // recovery (4) exceeds protected-or-merge (3). + expect(gitDeliveryRiskClassWithinCeiling("recovery", "protected-or-merge")).toBe(false); + // The comparison reads ordinals, not action-name strings. + expect(GIT_DELIVERY_RISK_CLASS_SEVERITY["recovery-or-rewrite"]).toBeGreaterThan( + GIT_DELIVERY_RISK_CLASS_SEVERITY.publish, + ); + }); +}); + +describe("typed branch matchers", () => { + it("matches exact patterns only on full equality", () => { + const exact: GitDeliveryBranchPattern = { matchKind: "exact", value: "main" }; + expect(gitDeliveryBranchNameMatchesPattern("main", exact)).toBe(true); + expect(gitDeliveryBranchNameMatchesPattern("main-2", exact)).toBe(false); + expect(gitDeliveryBranchNameMatchesPattern("release/main", exact)).toBe(false); + }); + + it("matches prefix patterns on startsWith", () => { + const prefix: GitDeliveryBranchPattern = { matchKind: "prefix", value: "release/" }; + expect(gitDeliveryBranchNameMatchesPattern("release/1.0", prefix)).toBe(true); + expect(gitDeliveryBranchNameMatchesPattern("release/", prefix)).toBe(true); + expect(gitDeliveryBranchNameMatchesPattern("hotfix/1", prefix)).toBe(false); + }); + + it("gitDeliveryBranchNameMatchesAny returns true when any pattern matches", () => { + const patterns: readonly GitDeliveryBranchPattern[] = [ + { matchKind: "exact", value: "main" }, + { matchKind: "prefix", value: "release/" }, + ]; + expect(gitDeliveryBranchNameMatchesAny("main", patterns)).toBe(true); + expect(gitDeliveryBranchNameMatchesAny("release/9", patterns)).toBe(true); + expect(gitDeliveryBranchNameMatchesAny("feature/x", patterns)).toBe(false); + expect(gitDeliveryBranchNameMatchesAny("main", [])).toBe(false); + }); +}); diff --git a/packages/keiko-contracts/src/git-delivery.ts b/packages/keiko-contracts/src/git-delivery.ts new file mode 100644 index 000000000..5501e6320 --- /dev/null +++ b/packages/keiko-contracts/src/git-delivery.ts @@ -0,0 +1,849 @@ +// Public type contracts for the governed Git delivery surface (Issue #471, Epic #470). +// Ownership: git-delivery domain. Disjoint from workflow-handoff.ts (one-shot agent invocation) +// and editor-agent.ts (editor bridge). Leaf-package rules (ADR-0019): pure types and frozen +// const tables only. No IO, no clock, no crypto, no randomness. Opaque hex hashes are produced +// by callers (sha256Hex in stableId.ts or equivalent). Relative imports end in ".js". +// +// This is the CORE atom module for the git-delivery surface. It imports NOTHING from its two +// siblings (git-delivery-policy.ts / git-delivery-provider.ts). The siblings import FROM here, so +// the dependency graph is a one-directional DAG with no cycles (verified by arch:check). The only +// internal import is `./workflow-handoff.js` for `isApprovalTokenShape` (a legal intra-package +// relative import; keiko-contracts modules may reference each other). + +import { isApprovalTokenShape } from "./workflow-handoff.js"; + +export const GIT_DELIVERY_SCHEMA_VERSION = "1" as const; + +// ─── Action kinds ───────────────────────────────────────────────────────────── + +export type GitDeliveryActionKind = + | "branch-create" + | "stage" + | "unstage" + | "commit" + | "push" + | "pr-create" + | "pr-update" + | "merge" + | "abort" + | "recovery"; + +export const GIT_DELIVERY_ACTION_KINDS: readonly GitDeliveryActionKind[] = [ + "branch-create", + "stage", + "unstage", + "commit", + "push", + "pr-create", + "pr-update", + "merge", + "abort", + "recovery", +] as const; + +// ─── Risk taxonomy ──────────────────────────────────────────────────────────── +// AC2: severity is DATA, never inferred from kind names or shell arguments. +// Read GIT_DELIVERY_RISK_CLASS_SEVERITY[riskClass] to compare severity numerically. + +export type GitDeliveryRiskClass = + | "local-mutation" + | "publish" + | "protected-or-merge" + | "recovery-or-rewrite"; + +export const GIT_DELIVERY_RISK_CLASSES: readonly GitDeliveryRiskClass[] = [ + "local-mutation", + "publish", + "protected-or-merge", + "recovery-or-rewrite", +] as const; + +// Ordinal severity. Higher ordinal = higher risk. Never compare ordinals by action name string. +export const GIT_DELIVERY_RISK_CLASS_SEVERITY: Readonly> = { + "local-mutation": 1, + publish: 2, + "protected-or-merge": 3, + "recovery-or-rewrite": 4, +} as const; + +// Default risk class per action kind. Keys are ordered to match GIT_DELIVERY_ACTION_KINDS exactly. +// Unknown kinds (post-deserialization) default to the highest class via gitDeliveryDefaultRiskClass +// — this table covers all known kinds exactly. +export const GIT_DELIVERY_ACTION_RISK_DEFAULTS: Readonly< + Record +> = { + "branch-create": "local-mutation", + stage: "local-mutation", + unstage: "local-mutation", + commit: "local-mutation", + push: "publish", + "pr-create": "protected-or-merge", + "pr-update": "protected-or-merge", + merge: "protected-or-merge", + abort: "local-mutation", + recovery: "recovery-or-rewrite", +} as const; + +// ─── Per-kind resolved inputs (discriminated union) ───────────────────────────── +// Each member is a separate readonly interface. Only fields semantically required for that kind +// appear — no kind leaks another kind's fields. + +export interface GitDeliveryBranchCreateInputs { + readonly kind: "branch-create"; + readonly branchName: string; + readonly baseBranchName: string; + readonly startPointRefHash: string; // opaque commit SHA +} + +export interface GitDeliveryStageInputs { + readonly kind: "stage"; + readonly pathCount: number; // count only — no raw paths in the contract layer + readonly includesUntracked: boolean; +} + +export interface GitDeliveryUnstageInputs { + readonly kind: "unstage"; + readonly pathCount: number; +} + +export interface GitDeliveryCommitInputs { + readonly kind: "commit"; + readonly messageByteLength: number; // length, not content + readonly stagedPathCount: number; + readonly allowEmptyCommit: boolean; +} + +export interface GitDeliveryPushInputs { + readonly kind: "push"; + readonly sourceBranchName: string; + readonly remoteAlias: string; + readonly remoteBranchName: string; + readonly forcePush: boolean; + readonly setUpstreamTracking: boolean; +} + +export interface GitDeliveryPrCreateInputs { + readonly kind: "pr-create"; + readonly headBranchName: string; + readonly baseBranchName: string; + readonly titleByteLength: number; + readonly bodyByteLength: number; + readonly isDraft: boolean; +} + +export interface GitDeliveryPrUpdateInputs { + readonly kind: "pr-update"; + readonly prExternalId: string; // opaque provider-assigned ID + readonly headBranchName: string; + readonly baseBranchName: string; + readonly titleByteLength: number; + readonly bodyByteLength: number; + readonly convertToDraft: boolean; + readonly convertFromDraft: boolean; +} + +export type GitDeliveryMergeStrategyHint = + | "squash" + | "rebase" + | "merge-commit" + | "provider-default"; + +export const GIT_DELIVERY_MERGE_STRATEGY_HINTS: readonly GitDeliveryMergeStrategyHint[] = [ + "squash", + "rebase", + "merge-commit", + "provider-default", +] as const; + +export interface GitDeliveryMergeInputs { + readonly kind: "merge"; + readonly prExternalId: string; + readonly mergeStrategyHint: GitDeliveryMergeStrategyHint; + readonly deleteBranchAfterMerge: boolean; +} + +export type GitDeliveryAbortableOperation = + | "merge" + | "rebase" + | "cherry-pick" + | "revert" + | "bisect"; + +export const GIT_DELIVERY_ABORTABLE_OPERATIONS: readonly GitDeliveryAbortableOperation[] = [ + "merge", + "rebase", + "cherry-pick", + "revert", + "bisect", +] as const; + +export interface GitDeliveryAbortInputs { + readonly kind: "abort"; + readonly operationToAbort: GitDeliveryAbortableOperation; + readonly preserveIndexChanges: boolean; +} + +export type GitDeliveryRecoveryStrategyHint = + | "soft-reset" + | "mixed-reset" + | "stash-and-reset" + | "restore-index"; + +export const GIT_DELIVERY_RECOVERY_STRATEGY_HINTS: readonly GitDeliveryRecoveryStrategyHint[] = [ + "soft-reset", + "mixed-reset", + "stash-and-reset", + "restore-index", +] as const; + +// Recovery's elevated-approval requirement is expressed by policyDecision / approvalRequirement on +// the envelope, NOT by an operator-approval token in the inputs. +export interface GitDeliveryRecoveryInputs { + readonly kind: "recovery"; + readonly recoveryStrategyHint: GitDeliveryRecoveryStrategyHint; + readonly targetRefHash: string; // opaque commit SHA + readonly affectedPathCount: number; +} + +// The discriminated union. Exhaustive on kind. +export type GitDeliveryResolvedInputs = + | GitDeliveryBranchCreateInputs + | GitDeliveryStageInputs + | GitDeliveryUnstageInputs + | GitDeliveryCommitInputs + | GitDeliveryPushInputs + | GitDeliveryPrCreateInputs + | GitDeliveryPrUpdateInputs + | GitDeliveryMergeInputs + | GitDeliveryAbortInputs + | GitDeliveryRecoveryInputs; + +// ─── Approval-intent model ────────────────────────────────────────────────────── +// Discriminated on `required`. Reuses isApprovalTokenShape (workflow-handoff.ts). + +export interface GitDeliveryApprovalNotRequired { + readonly required: false; +} + +export interface GitDeliveryApprovalGranted { + readonly required: true; + // Hash of the approval token, not the token itself. Validated by isApprovalTokenShape (64-hex). + readonly approvalTokenHash: string; + readonly approvedByUserId: string; + readonly approvedAtMs: number; + readonly expiresAtMs?: number | undefined; +} + +export type GitDeliveryApprovalRequirement = + | GitDeliveryApprovalNotRequired + | GitDeliveryApprovalGranted; + +// ─── Provider capability (owned here per ADR-0019 cycle-break) ─────────────────── +// Owned by the core atom so both policy.ts (constraint) and provider.ts (descriptor) can import it +// without a cycle. + +export type GitDeliveryProviderCapability = + | "branch-protection" + | "draft-pr" + | "required-checks" + | "merge-queue" + | "protected-branch-delete"; + +export const GIT_DELIVERY_PROVIDER_CAPABILITIES: readonly GitDeliveryProviderCapability[] = [ + "branch-protection", + "draft-pr", + "required-checks", + "merge-queue", + "protected-branch-delete", +] as const; + +// ─── Typed branch patterns (AC3 — structured data, not parsed strings) ─────────── +// Closed match set: exact, prefix. Glob is intentionally EXCLUDED to keep the leaf parse-free +// (documented in ADR-0058). + +export type GitDeliveryBranchMatchKind = "exact" | "prefix"; + +export const GIT_DELIVERY_BRANCH_MATCH_KINDS: readonly GitDeliveryBranchMatchKind[] = [ + "exact", + "prefix", +] as const; + +export interface GitDeliveryBranchPattern { + readonly matchKind: GitDeliveryBranchMatchKind; + readonly value: string; +} + +export interface GitDeliveryBranchPatternConstraint { + readonly kind: "branch-pattern"; + readonly patterns: readonly GitDeliveryBranchPattern[]; +} + +export interface GitDeliveryProviderCapabilityConstraint { + readonly kind: "provider-capability"; + readonly capability: GitDeliveryProviderCapability; +} + +// A genuine ceiling: actions whose default risk class severity EXCEEDS maxRiskClass are out of +// bounds. (Replaces the misnamed/circular "min-risk-class" constraint.) +export interface GitDeliveryRiskClassCeilingConstraint { + readonly kind: "risk-class-ceiling"; + readonly maxRiskClass: GitDeliveryRiskClass; +} + +export type GitDeliveryConstraint = + | GitDeliveryBranchPatternConstraint + | GitDeliveryProviderCapabilityConstraint + | GitDeliveryRiskClassCeilingConstraint; + +// ─── Policy decision ──────────────────────────────────────────────────────────── + +export type GitDeliveryBlockReason = + | "policy-pack-blocked" + | "protected-branch" + | "provider-capability-absent" + | "approval-expired" + | "risk-class-ceiling" + | "no-applicable-rule"; // fail-closed when neither level has a rule or defaultRule + +export const GIT_DELIVERY_BLOCK_REASONS: readonly GitDeliveryBlockReason[] = [ + "policy-pack-blocked", + "protected-branch", + "provider-capability-absent", + "approval-expired", + "risk-class-ceiling", + "no-applicable-rule", +] as const; + +export type GitDeliveryMergeBlockReason = + | "checks-failing" + | "approvals-missing" + | "conflicts" + | "branch-protection" + | "merge-queue-position" + | "provider-policy"; + +export const GIT_DELIVERY_MERGE_BLOCK_REASONS: readonly GitDeliveryMergeBlockReason[] = [ + "checks-failing", + "approvals-missing", + "conflicts", + "branch-protection", + "merge-queue-position", + "provider-policy", +] as const; + +export type GitDeliveryPolicyDecision = + | { readonly outcome: "allowed" } + | { readonly outcome: "blocked"; readonly reason: GitDeliveryBlockReason } + | { readonly outcome: "approval-gated"; readonly requiredApprovers: readonly string[] } + | { readonly outcome: "constrained"; readonly constraints: readonly GitDeliveryConstraint[] }; + +// ─── Preview and result (content-free) ────────────────────────────────────────── +// Content-free preview descriptor: counts, flags, affected branch name only. Never carries diff +// content, file paths, secrets, or command strings. + +export interface GitDeliveryActionPreview { + readonly schemaVersion: typeof GIT_DELIVERY_SCHEMA_VERSION; + readonly affectedBranchName?: string | undefined; + readonly estimatedFileCount?: number | undefined; + readonly estimatedBytesDelta?: number | undefined; + readonly wouldCreateRemoteBranch: boolean; + readonly wouldTriggerChecks: boolean; +} + +export type GitDeliveryExecutionOutcome = "succeeded" | "failed" | "aborted" | "partial"; + +export const GIT_DELIVERY_EXECUTION_OUTCOMES: readonly GitDeliveryExecutionOutcome[] = [ + "succeeded", + "failed", + "aborted", + "partial", +] as const; + +export type GitDeliveryExecutionErrorCode = + | "provider-rejected" + | "network-failure" + | "conflict" + | "precondition-failed" + | "timeout" + | "internal-error"; + +export const GIT_DELIVERY_EXECUTION_ERROR_CODES: readonly GitDeliveryExecutionErrorCode[] = [ + "provider-rejected", + "network-failure", + "conflict", + "precondition-failed", + "timeout", + "internal-error", +] as const; + +export interface GitDeliveryPartialDetail { + readonly attemptedUnitCount: number; + readonly succeededUnitCount: number; +} + +export interface GitDeliveryExecutionResult { + readonly schemaVersion: typeof GIT_DELIVERY_SCHEMA_VERSION; + readonly outcome: GitDeliveryExecutionOutcome; + readonly durationMs: number; + // Opaque provider-assigned external ID (e.g. PR number, commit SHA) — never raw output. + readonly externalId?: string | undefined; + readonly errorCode?: GitDeliveryExecutionErrorCode | undefined; + readonly partialDetail?: GitDeliveryPartialDetail | undefined; +} + +// Content-free evidence reference. Never raw diffs, command output, or secrets. Field naming aligns +// with evidence.ts (EvidenceGovernedWorkflowHandoff): sourceGroundedRunId + manifest stable-id hash. +export interface GitDeliveryEvidenceRef { + readonly sourceGroundedRunId: string; + readonly evidenceManifestStableIdHash: string; // SHA-256 hex +} + +// ─── Lifecycle envelope (AC1) ─────────────────────────────────────────────────── +// Sound discriminated union: kind === resolvedInputs.kind holds by construction. Each member is +// parameterised by its per-kind resolved-input type; GitDeliveryActionEnvelope is the union over +// all ten members. + +export interface GitDeliveryActionEnvelopeFor { + readonly schemaVersion: typeof GIT_DELIVERY_SCHEMA_VERSION; + readonly actionId: string; + readonly kind: I["kind"]; + readonly resolvedInputs: I; + readonly policyDecision: GitDeliveryPolicyDecision; + readonly approvalRequirement: GitDeliveryApprovalRequirement; + readonly preview?: GitDeliveryActionPreview | undefined; + readonly executionResult?: GitDeliveryExecutionResult | undefined; + readonly evidenceRef?: GitDeliveryEvidenceRef | undefined; +} + +export type GitDeliveryActionEnvelope = + | GitDeliveryActionEnvelopeFor + | GitDeliveryActionEnvelopeFor + | GitDeliveryActionEnvelopeFor + | GitDeliveryActionEnvelopeFor + | GitDeliveryActionEnvelopeFor + | GitDeliveryActionEnvelopeFor + | GitDeliveryActionEnvelopeFor + | GitDeliveryActionEnvelopeFor + | GitDeliveryActionEnvelopeFor + | GitDeliveryActionEnvelopeFor; + +// ─── Shared parse-result discriminated union ───────────────────────────────────── +// Defined ONCE here; policy/provider parsers reuse it. + +export type GitDeliveryParseResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly errors: readonly string[] }; + +// ─── Private predicate helpers ─────────────────────────────────────────────────── + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isString(value: unknown): value is string { + return typeof value === "string"; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +function isBoolean(value: unknown): value is boolean { + return typeof value === "boolean"; +} + +function isStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every(isString); +} + +function isUndefinedOr(check: (v: unknown) => v is T): (v: unknown) => v is T | undefined { + return (v: unknown): v is T | undefined => v === undefined || check(v); +} + +// ─── Exported guards ───────────────────────────────────────────────────────────── + +export function isGitDeliveryActionKind(value: unknown): value is GitDeliveryActionKind { + return isString(value) && (GIT_DELIVERY_ACTION_KINDS as readonly string[]).includes(value); +} + +export function isGitDeliveryRiskClass(value: unknown): value is GitDeliveryRiskClass { + return isString(value) && (GIT_DELIVERY_RISK_CLASSES as readonly string[]).includes(value); +} + +export function isGitDeliveryProviderCapability( + value: unknown, +): value is GitDeliveryProviderCapability { + return ( + isString(value) && (GIT_DELIVERY_PROVIDER_CAPABILITIES as readonly string[]).includes(value) + ); +} + +export function isGitDeliveryBranchMatchKind(value: unknown): value is GitDeliveryBranchMatchKind { + return isString(value) && (GIT_DELIVERY_BRANCH_MATCH_KINDS as readonly string[]).includes(value); +} + +export function isGitDeliveryBlockReason(value: unknown): value is GitDeliveryBlockReason { + return isString(value) && (GIT_DELIVERY_BLOCK_REASONS as readonly string[]).includes(value); +} + +export function isGitDeliveryMergeBlockReason( + value: unknown, +): value is GitDeliveryMergeBlockReason { + return isString(value) && (GIT_DELIVERY_MERGE_BLOCK_REASONS as readonly string[]).includes(value); +} + +export function isGitDeliveryMergeStrategyHint( + value: unknown, +): value is GitDeliveryMergeStrategyHint { + return ( + isString(value) && (GIT_DELIVERY_MERGE_STRATEGY_HINTS as readonly string[]).includes(value) + ); +} + +export function isGitDeliveryAbortableOperation( + value: unknown, +): value is GitDeliveryAbortableOperation { + return ( + isString(value) && (GIT_DELIVERY_ABORTABLE_OPERATIONS as readonly string[]).includes(value) + ); +} + +export function isGitDeliveryRecoveryStrategyHint( + value: unknown, +): value is GitDeliveryRecoveryStrategyHint { + return ( + isString(value) && (GIT_DELIVERY_RECOVERY_STRATEGY_HINTS as readonly string[]).includes(value) + ); +} + +export function isGitDeliveryExecutionOutcome( + value: unknown, +): value is GitDeliveryExecutionOutcome { + return isString(value) && (GIT_DELIVERY_EXECUTION_OUTCOMES as readonly string[]).includes(value); +} + +export function isGitDeliveryExecutionErrorCode( + value: unknown, +): value is GitDeliveryExecutionErrorCode { + return ( + isString(value) && (GIT_DELIVERY_EXECUTION_ERROR_CODES as readonly string[]).includes(value) + ); +} + +export function isGitDeliveryBranchPattern(value: unknown): value is GitDeliveryBranchPattern { + return ( + isRecord(value) && + isGitDeliveryBranchMatchKind(value.matchKind) && + isNonEmptyString(value.value) + ); +} + +export function isGitDeliveryConstraint(value: unknown): value is GitDeliveryConstraint { + if (!isRecord(value)) { + return false; + } + if (value.kind === "branch-pattern") { + return Array.isArray(value.patterns) && value.patterns.every(isGitDeliveryBranchPattern); + } + if (value.kind === "provider-capability") { + return isGitDeliveryProviderCapability(value.capability); + } + if (value.kind === "risk-class-ceiling") { + return isGitDeliveryRiskClass(value.maxRiskClass); + } + return false; +} + +function isApprovalGranted(value: Record): boolean { + return ( + value.required === true && + isString(value.approvalTokenHash) && + isApprovalTokenShape(value.approvalTokenHash) && + isNonEmptyString(value.approvedByUserId) && + isNonNegativeInteger(value.approvedAtMs) && + isUndefinedOr(isNonNegativeInteger)(value.expiresAtMs) + ); +} + +export function isGitDeliveryApprovalRequirement( + value: unknown, +): value is GitDeliveryApprovalRequirement { + if (!isRecord(value)) { + return false; + } + if (value.required === false) { + return true; + } + return isApprovalGranted(value); +} + +export function isGitDeliveryPolicyDecision(value: unknown): value is GitDeliveryPolicyDecision { + if (!isRecord(value)) { + return false; + } + if (value.outcome === "allowed") { + return true; + } + if (value.outcome === "blocked") { + return isGitDeliveryBlockReason(value.reason); + } + if (value.outcome === "approval-gated") { + return isStringArray(value.requiredApprovers); + } + if (value.outcome === "constrained") { + return Array.isArray(value.constraints) && value.constraints.every(isGitDeliveryConstraint); + } + return false; +} + +export function isGitDeliveryEvidenceRef(value: unknown): value is GitDeliveryEvidenceRef { + return ( + isRecord(value) && + isNonEmptyString(value.sourceGroundedRunId) && + isNonEmptyString(value.evidenceManifestStableIdHash) + ); +} + +function isPartialDetail(value: unknown): value is GitDeliveryPartialDetail { + return ( + isRecord(value) && + isNonNegativeInteger(value.attemptedUnitCount) && + isNonNegativeInteger(value.succeededUnitCount) + ); +} + +export function isGitDeliveryExecutionResult(value: unknown): value is GitDeliveryExecutionResult { + return ( + isRecord(value) && + value.schemaVersion === GIT_DELIVERY_SCHEMA_VERSION && + isGitDeliveryExecutionOutcome(value.outcome) && + isNonNegativeInteger(value.durationMs) && + isUndefinedOr(isNonEmptyString)(value.externalId) && + isUndefinedOr(isGitDeliveryExecutionErrorCode)(value.errorCode) && + isUndefinedOr(isPartialDetail)(value.partialDetail) + ); +} + +function isActionPreview(value: unknown): value is GitDeliveryActionPreview { + return ( + isRecord(value) && + value.schemaVersion === GIT_DELIVERY_SCHEMA_VERSION && + isUndefinedOr(isNonEmptyString)(value.affectedBranchName) && + isUndefinedOr(isNonNegativeInteger)(value.estimatedFileCount) && + isUndefinedOr(isNonNegativeInteger)(value.estimatedBytesDelta) && + isBoolean(value.wouldCreateRemoteBranch) && + isBoolean(value.wouldTriggerChecks) + ); +} + +// ─── Per-kind resolved-input guards (one per kind for the dispatch lookup) ──────── + +function isBranchCreateInputs(value: Record): boolean { + return ( + isNonEmptyString(value.branchName) && + isNonEmptyString(value.baseBranchName) && + isNonEmptyString(value.startPointRefHash) + ); +} + +function isStageInputs(value: Record): boolean { + return isNonNegativeInteger(value.pathCount) && isBoolean(value.includesUntracked); +} + +function isUnstageInputs(value: Record): boolean { + return isNonNegativeInteger(value.pathCount); +} + +function isCommitInputs(value: Record): boolean { + return ( + isNonNegativeInteger(value.messageByteLength) && + isNonNegativeInteger(value.stagedPathCount) && + isBoolean(value.allowEmptyCommit) + ); +} + +function isPushInputs(value: Record): boolean { + return ( + isNonEmptyString(value.sourceBranchName) && + isNonEmptyString(value.remoteAlias) && + isNonEmptyString(value.remoteBranchName) && + isBoolean(value.forcePush) && + isBoolean(value.setUpstreamTracking) + ); +} + +function isPrCreateInputs(value: Record): boolean { + return ( + isNonEmptyString(value.headBranchName) && + isNonEmptyString(value.baseBranchName) && + isNonNegativeInteger(value.titleByteLength) && + isNonNegativeInteger(value.bodyByteLength) && + isBoolean(value.isDraft) + ); +} + +function isPrUpdateInputs(value: Record): boolean { + return ( + isNonEmptyString(value.prExternalId) && + isNonEmptyString(value.headBranchName) && + isNonEmptyString(value.baseBranchName) && + isNonNegativeInteger(value.titleByteLength) && + isNonNegativeInteger(value.bodyByteLength) && + isBoolean(value.convertToDraft) && + isBoolean(value.convertFromDraft) + ); +} + +function isMergeInputs(value: Record): boolean { + return ( + isNonEmptyString(value.prExternalId) && + isGitDeliveryMergeStrategyHint(value.mergeStrategyHint) && + isBoolean(value.deleteBranchAfterMerge) + ); +} + +function isAbortInputs(value: Record): boolean { + return ( + isGitDeliveryAbortableOperation(value.operationToAbort) && isBoolean(value.preserveIndexChanges) + ); +} + +function isRecoveryInputs(value: Record): boolean { + return ( + isGitDeliveryRecoveryStrategyHint(value.recoveryStrategyHint) && + isNonEmptyString(value.targetRefHash) && + isNonNegativeInteger(value.affectedPathCount) + ); +} + +const RESOLVED_INPUT_GUARDS: Readonly< + Record) => boolean> +> = { + "branch-create": isBranchCreateInputs, + stage: isStageInputs, + unstage: isUnstageInputs, + commit: isCommitInputs, + push: isPushInputs, + "pr-create": isPrCreateInputs, + "pr-update": isPrUpdateInputs, + merge: isMergeInputs, + abort: isAbortInputs, + recovery: isRecoveryInputs, +} as const; + +// ─── Parsers ───────────────────────────────────────────────────────────────────── + +export function parseGitDeliveryResolvedInputs( + value: unknown, +): GitDeliveryParseResult { + if (!isRecord(value)) { + return { ok: false, errors: ["resolvedInputs must be an object"] }; + } + if (!isGitDeliveryActionKind(value.kind)) { + return { ok: false, errors: ["resolvedInputs.kind must be a known GitDeliveryActionKind"] }; + } + const guard = RESOLVED_INPUT_GUARDS[value.kind]; + if (!guard(value)) { + return { ok: false, errors: [`resolvedInputs for kind "${value.kind}" has invalid fields`] }; + } + return { ok: true, value: value as unknown as GitDeliveryResolvedInputs }; +} + +function envelopeFieldErrors(value: Record): readonly string[] { + const errors: string[] = []; + if (value.schemaVersion !== GIT_DELIVERY_SCHEMA_VERSION) { + errors.push("envelope.schemaVersion must equal the GIT_DELIVERY_SCHEMA_VERSION literal"); + } + if (!isNonEmptyString(value.actionId)) { + errors.push("envelope.actionId must be a non-empty string"); + } + if (!isGitDeliveryPolicyDecision(value.policyDecision)) { + errors.push("envelope.policyDecision is not a valid GitDeliveryPolicyDecision"); + } + if (!isGitDeliveryApprovalRequirement(value.approvalRequirement)) { + errors.push("envelope.approvalRequirement is not a valid GitDeliveryApprovalRequirement"); + } + if (!isUndefinedOr(isActionPreview)(value.preview)) { + errors.push("envelope.preview is not a valid GitDeliveryActionPreview"); + } + if (!isUndefinedOr(isGitDeliveryExecutionResult)(value.executionResult)) { + errors.push("envelope.executionResult is not a valid GitDeliveryExecutionResult"); + } + if (!isUndefinedOr(isGitDeliveryEvidenceRef)(value.evidenceRef)) { + errors.push("envelope.evidenceRef is not a valid GitDeliveryEvidenceRef"); + } + return errors; +} + +export function parseGitDeliveryActionEnvelope( + value: unknown, +): GitDeliveryParseResult { + if (!isRecord(value)) { + return { ok: false, errors: ["envelope must be an object"] }; + } + const inputs = parseGitDeliveryResolvedInputs(value.resolvedInputs); + if (!inputs.ok) { + return { ok: false, errors: inputs.errors }; + } + if (value.kind !== inputs.value.kind) { + return { ok: false, errors: ["envelope.kind must equal envelope.resolvedInputs.kind"] }; + } + const fieldErrors = envelopeFieldErrors(value); + if (fieldErrors.length > 0) { + return { ok: false, errors: fieldErrors }; + } + return { ok: true, value: value as unknown as GitDeliveryActionEnvelope }; +} + +// ─── Risk-class lookups ────────────────────────────────────────────────────────── + +// Fail-closed risk lookup: unknown kinds → "recovery-or-rewrite" (ordinal 4). +export function gitDeliveryDefaultRiskClass(kind: string): GitDeliveryRiskClass { + if (isGitDeliveryActionKind(kind)) { + return GIT_DELIVERY_ACTION_RISK_DEFAULTS[kind]; + } + return "recovery-or-rewrite"; +} + +// Inputs-aware classifier: a force-push escalates beyond the default "publish" class so it is never +// under-classified relative to its true blast radius. +export function gitDeliveryRiskClassForInputs( + inputs: GitDeliveryResolvedInputs, +): GitDeliveryRiskClass { + if (inputs.kind === "push" && inputs.forcePush) { + return "recovery-or-rewrite"; + } + return gitDeliveryDefaultRiskClass(inputs.kind); +} + +// True when the action kind's default risk severity is at or below the ceiling severity. +export function gitDeliveryRiskClassWithinCeiling( + actionKind: GitDeliveryActionKind, + ceiling: GitDeliveryRiskClass, +): boolean { + const actionSeverity = GIT_DELIVERY_RISK_CLASS_SEVERITY[gitDeliveryDefaultRiskClass(actionKind)]; + return actionSeverity <= GIT_DELIVERY_RISK_CLASS_SEVERITY[ceiling]; +} + +// ─── Branch matchers ───────────────────────────────────────────────────────────── + +export function gitDeliveryBranchNameMatchesPattern( + branchName: string, + pattern: GitDeliveryBranchPattern, +): boolean { + if (pattern.matchKind === "exact") { + return branchName === pattern.value; + } + return branchName.startsWith(pattern.value); +} + +export function gitDeliveryBranchNameMatchesAny( + branchName: string, + patterns: readonly GitDeliveryBranchPattern[], +): boolean { + return patterns.some((pattern) => gitDeliveryBranchNameMatchesPattern(branchName, pattern)); +} diff --git a/packages/keiko-contracts/src/index.test.ts b/packages/keiko-contracts/src/index.test.ts index 11dbe7566..069aae66d 100644 --- a/packages/keiko-contracts/src/index.test.ts +++ b/packages/keiko-contracts/src/index.test.ts @@ -124,6 +124,35 @@ import type { CompletionInteractionMode, CompletionDegradeReason, CompletionModelSelection, + GitDeliveryActionEnvelope, + GitDeliveryResolvedInputs, + GitDeliveryConstraint, + GitDeliveryPolicyDecision, + GitDeliveryBranchProtection, + GitDeliveryMergeReadiness, + GitDeliveryPullRequestState, +} from "./index.js"; +import { + GIT_DELIVERY_SCHEMA_VERSION, + GIT_DELIVERY_POLICY_SCHEMA_VERSION, + GIT_DELIVERY_PROVIDER_SCHEMA_VERSION, + GIT_DELIVERY_ACTION_KINDS, + GIT_DELIVERY_RISK_CLASSES, + GIT_DELIVERY_RISK_CLASS_SEVERITY, + GIT_DELIVERY_ACTION_RISK_DEFAULTS, + GIT_DELIVERY_BLOCK_REASONS, + GIT_DELIVERY_PROVIDER_CAPABILITIES, + GIT_DELIVERY_RULE_DECISIONS, + GIT_DELIVERY_CHECKS_OVERALL_STATUSES, + GIT_DELIVERY_PULL_REQUEST_STATUSES, + GIT_DELIVERY_BRANCH_MATCH_KINDS, + GIT_DELIVERY_EXECUTION_ERROR_CODES, + GIT_DELIVERY_EXECUTION_OUTCOMES, + GIT_DELIVERY_MERGE_BLOCK_REASONS, + isGitDeliveryActionKind, + gitDeliveryDefaultRiskClass, + evaluateGitPolicy, + parseGitDeliveryActionEnvelope, } from "./index.js"; describe("keiko-contracts package surface", () => { @@ -516,4 +545,49 @@ describe("keiko-contracts package surface", () => { const pin = (_value?: T): T | undefined => undefined; pin(); }); + + it("governed Git delivery contracts are reachable through the barrel (#471)", () => { + // Schema versions. + expect(GIT_DELIVERY_SCHEMA_VERSION).toBe("1"); + expect(GIT_DELIVERY_POLICY_SCHEMA_VERSION).toBe("1"); + expect(GIT_DELIVERY_PROVIDER_SCHEMA_VERSION).toBe("1"); + + // Count assertions are intentional surface pins; bump deliberately when #472+ extends the surface. + expect(GIT_DELIVERY_ACTION_KINDS.length).toBe(10); + expect(GIT_DELIVERY_RISK_CLASSES.length).toBe(4); + expect(GIT_DELIVERY_BLOCK_REASONS.length).toBe(6); + expect(GIT_DELIVERY_PROVIDER_CAPABILITIES.length).toBe(5); + expect(GIT_DELIVERY_RULE_DECISIONS.length).toBe(4); + expect(GIT_DELIVERY_CHECKS_OVERALL_STATUSES.length).toBe(4); + expect(GIT_DELIVERY_PULL_REQUEST_STATUSES.length).toBe(3); + expect(GIT_DELIVERY_BRANCH_MATCH_KINDS.length).toBe(2); + expect(GIT_DELIVERY_EXECUTION_ERROR_CODES.length).toBe(6); + expect(GIT_DELIVERY_EXECUTION_OUTCOMES.length).toBe(4); + expect(GIT_DELIVERY_MERGE_BLOCK_REASONS.length).toBe(6); + + // Risk severity and per-kind defaults cover every kind. + expect(GIT_DELIVERY_RISK_CLASS_SEVERITY["local-mutation"]).toBe(1); + expect(GIT_DELIVERY_RISK_CLASS_SEVERITY["recovery-or-rewrite"]).toBe(4); + for (const kind of GIT_DELIVERY_ACTION_KINDS) { + expect(GIT_DELIVERY_ACTION_RISK_DEFAULTS[kind]).toBeDefined(); + } + + // Value-level functions are reachable. + expect(typeof isGitDeliveryActionKind).toBe("function"); + expect(typeof gitDeliveryDefaultRiskClass).toBe("function"); + expect(typeof evaluateGitPolicy).toBe("function"); + expect(typeof parseGitDeliveryActionEnvelope).toBe("function"); + expect(gitDeliveryDefaultRiskClass("unknown-future-kind")).toBe("recovery-or-rewrite"); + expect(isGitDeliveryActionKind("commit")).toBe(true); + + // Type pins (compile-time reachability). + const pin = (_value?: T): T | undefined => undefined; + pin(); + pin(); + pin(); + pin(); + pin(); + pin(); + pin(); + }); }); diff --git a/packages/keiko-contracts/src/index.ts b/packages/keiko-contracts/src/index.ts index 81d960c0d..b41b56f68 100644 --- a/packages/keiko-contracts/src/index.ts +++ b/packages/keiko-contracts/src/index.ts @@ -1392,3 +1392,134 @@ export { PROMPT_ENHANCEMENT_MODEL_AVAILABILITIES, validatePromptEnhancementWireRequest, } from "./prompt-enhancer-bff.js"; + +// ─── Governed Git delivery contracts (Issue #471, Epic #470; ADR-0058) ─────────── +// The core atom git-delivery.ts owns action kinds, risk taxonomy, the lifecycle envelope, the +// typed constraint union, the policy decision, provider capability, and the shared parse result. +// git-delivery-policy.ts owns the policy packs + the deterministic evaluator. git-delivery-provider.ts +// owns the provider-neutral interfaces. Each symbol is re-exported from whichever file owns it. + +// git-delivery.ts +export type { + GitDeliveryActionKind, + GitDeliveryRiskClass, + GitDeliveryMergeStrategyHint, + GitDeliveryAbortableOperation, + GitDeliveryRecoveryStrategyHint, + GitDeliveryProviderCapability, + GitDeliveryBranchMatchKind, + GitDeliveryBranchPattern, + GitDeliveryBranchPatternConstraint, + GitDeliveryProviderCapabilityConstraint, + GitDeliveryRiskClassCeilingConstraint, + GitDeliveryConstraint, + GitDeliveryBlockReason, + GitDeliveryMergeBlockReason, + GitDeliveryExecutionOutcome, + GitDeliveryExecutionErrorCode, + GitDeliveryPartialDetail, + GitDeliveryBranchCreateInputs, + GitDeliveryStageInputs, + GitDeliveryUnstageInputs, + GitDeliveryCommitInputs, + GitDeliveryPushInputs, + GitDeliveryPrCreateInputs, + GitDeliveryPrUpdateInputs, + GitDeliveryMergeInputs, + GitDeliveryAbortInputs, + GitDeliveryRecoveryInputs, + GitDeliveryResolvedInputs, + GitDeliveryApprovalNotRequired, + GitDeliveryApprovalGranted, + GitDeliveryApprovalRequirement, + GitDeliveryPolicyDecision, + GitDeliveryActionPreview, + GitDeliveryExecutionResult, + GitDeliveryEvidenceRef, + GitDeliveryActionEnvelopeFor, + GitDeliveryActionEnvelope, + GitDeliveryParseResult, +} from "./git-delivery.js"; +export { + GIT_DELIVERY_SCHEMA_VERSION, + GIT_DELIVERY_ACTION_KINDS, + GIT_DELIVERY_RISK_CLASSES, + GIT_DELIVERY_RISK_CLASS_SEVERITY, + GIT_DELIVERY_ACTION_RISK_DEFAULTS, + GIT_DELIVERY_MERGE_STRATEGY_HINTS, + GIT_DELIVERY_ABORTABLE_OPERATIONS, + GIT_DELIVERY_RECOVERY_STRATEGY_HINTS, + GIT_DELIVERY_PROVIDER_CAPABILITIES, + GIT_DELIVERY_BRANCH_MATCH_KINDS, + GIT_DELIVERY_EXECUTION_OUTCOMES, + GIT_DELIVERY_EXECUTION_ERROR_CODES, + GIT_DELIVERY_BLOCK_REASONS, + GIT_DELIVERY_MERGE_BLOCK_REASONS, + isGitDeliveryActionKind, + isGitDeliveryRiskClass, + isGitDeliveryProviderCapability, + isGitDeliveryBranchMatchKind, + isGitDeliveryBranchPattern, + isGitDeliveryConstraint, + isGitDeliveryBlockReason, + isGitDeliveryMergeBlockReason, + isGitDeliveryMergeStrategyHint, + isGitDeliveryAbortableOperation, + isGitDeliveryRecoveryStrategyHint, + isGitDeliveryExecutionOutcome, + isGitDeliveryExecutionErrorCode, + isGitDeliveryApprovalRequirement, + isGitDeliveryPolicyDecision, + isGitDeliveryEvidenceRef, + isGitDeliveryExecutionResult, + parseGitDeliveryResolvedInputs, + parseGitDeliveryActionEnvelope, + gitDeliveryDefaultRiskClass, + gitDeliveryRiskClassForInputs, + gitDeliveryRiskClassWithinCeiling, + gitDeliveryBranchNameMatchesPattern, + gitDeliveryBranchNameMatchesAny, +} from "./git-delivery.js"; + +// git-delivery-policy.ts +export type { + GitDeliveryRuleDecision, + GitDeliveryPolicyRule, + GitDeliveryDefaultRule, + GitDeliveryRepoPolicyPack, + GitDeliveryOrgPolicyPack, + GitDeliveryPolicyContext, +} from "./git-delivery-policy.js"; +export { + GIT_DELIVERY_POLICY_SCHEMA_VERSION, + GIT_DELIVERY_RULE_DECISIONS, + isGitDeliveryPolicyRule, + evaluateGitPolicy, + parseGitPolicyPack, + parseGitRepoPolicyPack, + parseGitOrgPolicyPack, +} from "./git-delivery-policy.js"; + +// git-delivery-provider.ts +export type { + GitDeliveryChecksOverallStatus, + GitDeliveryPullRequestStatus, + GitDeliveryBranchProtection, + GitDeliveryChecksState, + GitDeliveryMergeReadiness, + GitDeliveryPullRequestState, + GitDeliveryRemoteTargetPolicy, + GitDeliveryProviderDescriptor, +} from "./git-delivery-provider.js"; +export { + GIT_DELIVERY_PROVIDER_SCHEMA_VERSION, + GIT_DELIVERY_CHECKS_OVERALL_STATUSES, + GIT_DELIVERY_PULL_REQUEST_STATUSES, + isGitDeliveryChecksOverallStatus, + isGitDeliveryPullRequestStatus, + isGitDeliveryBranchProtection, + isGitDeliveryChecksState, + isGitDeliveryMergeReadiness, + isGitDeliveryPullRequestState, + isGitDeliveryProviderDescriptor, +} from "./git-delivery-provider.js"; diff --git a/packages/keiko-tools/src/terminal-policy.test.ts b/packages/keiko-tools/src/terminal-policy.test.ts index c2a034bf3..c7a2c3805 100644 --- a/packages/keiko-tools/src/terminal-policy.test.ts +++ b/packages/keiko-tools/src/terminal-policy.test.ts @@ -5,6 +5,7 @@ // least one assertion in this file. import { describe, expect, it } from "vitest"; +import { GIT_DELIVERY_ACTION_KINDS } from "@oscharko-dev/keiko-contracts"; import { TERMINAL_COMMAND_RULES, isTerminalCommandAllowed } from "./terminal-policy.js"; describe("TERMINAL_COMMAND_RULES", () => { @@ -423,3 +424,63 @@ describe("isTerminalCommandAllowed — bare-executable safety", () => { expect(isTerminalCommandAllowed("ls\\foo", []).allowed).toBe(false); }); }); + +describe("AC5 — governed Git delivery boundary (ADR-0058)", () => { + // The REAL underlying mutating git commands each governed action kind maps to must stay denied by + // isTerminalCommandAllowed. Governed write authority lives only behind the typed contracts; the + // human-facing terminal allowlist must never grow to reach any of these. + const DENIED_GIT_INVOCATIONS: readonly (readonly string[])[] = [ + // commit + ["commit", "-m", "x"], + // push (incl. force) + ["push", "origin", "main"], + ["push", "--force", "origin", "main"], + // merge + abort + ["merge", "feat"], + ["merge", "--abort"], + // branch-create + ["branch", "feat/x"], + // stage (the REAL stage command) + ["add", "."], + ["add", "-A"], + // unstage (the REAL unstage commands) + ["restore", "--staged", "."], + ["reset", "HEAD", "file"], + // recovery + ["reset", "--hard", "HEAD~1"], + ["restore", "."], + // rewrite-adjacent operations + ["rebase", "main"], + ["cherry-pick", "abc"], + ["revert", "abc"], + ["stash"], + ["clean", "-fd"], + ["tag", "v1"], + ["switch", "-c", "x"], + ["checkout", "-b", "x"], + ["fetch"], + ["pull"], + ] as const; + + it.each(DENIED_GIT_INVOCATIONS)("denies git %j as a mutating/network command", (...args) => { + expect(isTerminalCommandAllowed("git", args).allowed).toBe(false); + }); + + it("keeps read-only inspection commands allowed (selective, not deny-everything)", () => { + expect(isTerminalCommandAllowed("git", ["status"]).allowed).toBe(true); + expect(isTerminalCommandAllowed("git", ["log"]).allowed).toBe(true); + }); + + it("GIT_DELIVERY_ACTION_KINDS shares no member with any terminal allowedSubcommands", () => { + const allowedSubcommands = new Set(); + for (const rule of TERMINAL_COMMAND_RULES) { + for (const sub of rule.allowedSubcommands ?? []) { + allowedSubcommands.add(sub); + } + } + expect(allowedSubcommands.size).toBeGreaterThan(0); + for (const kind of GIT_DELIVERY_ACTION_KINDS) { + expect(allowedSubcommands.has(kind)).toBe(false); + } + }); +}); From 8659bb16b71fba3fbf23e0bd57be87130292f0fd Mon Sep 17 00:00:00 2001 From: Oliver Scharkowski <59687448+oscharko@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:37:23 +0200 Subject: [PATCH 02/16] fix(git-delivery): harden #471 contract validators per adversarial review (#1506) Stricter parse-time enum guards (actionKind, provider-capability) and typed GitDeliveryRemoteTargetPolicy push-target patterns, with 10 regression tests. Follow-up to #1503. Refs #471. --- ...DR-0058-governed-git-delivery-contracts.md | 2 +- .../src/git-delivery-policy.test.ts | 17 ++++ .../src/git-delivery-policy.ts | 8 +- .../src/git-delivery-provider.test.ts | 77 +++++++++++++++++++ .../src/git-delivery-provider.ts | 30 ++++++-- packages/keiko-contracts/src/index.test.ts | 2 + packages/keiko-contracts/src/index.ts | 1 + 7 files changed, 129 insertions(+), 8 deletions(-) diff --git a/docs/adr/ADR-0058-governed-git-delivery-contracts.md b/docs/adr/ADR-0058-governed-git-delivery-contracts.md index b3db39ad8..33ae1de41 100644 --- a/docs/adr/ADR-0058-governed-git-delivery-contracts.md +++ b/docs/adr/ADR-0058-governed-git-delivery-contracts.md @@ -259,7 +259,7 @@ The four neutral interfaces are: | `GitDeliveryChecksState` | Total, passing, failing, pending (counts); overall status as `passing | failing | pending | skipped` | | `GitDeliveryPullRequestState` | Neutral PR status: `open | closed | merged` (draftness is the orthogonal `isDraft: boolean`, not a status member); base branch name; head branch name; merge readiness reference | | `GitDeliveryMergeReadiness` | Ready boolean; blocking reason as `GitDeliveryMergeBlockReason` (typed union, not a string); required approval count (number) | -| `GitDeliveryRemoteTargetPolicy` | Allowed push targets as readonly string[]; force-push globally denied boolean | +| `GitDeliveryRemoteTargetPolicy` | Allowed push targets as typed GitDeliveryBranchPattern[] (exact/prefix); force-push globally denied boolean | | `GitDeliveryProviderCapability` | Named capability (`branch-protection | draft-pr | required-checks | merge-queue | protected-branch-delete`) — what the connected provider supports | **The rule that keeps providers out of the core:** No interface in `git-delivery-provider.ts` may diff --git a/packages/keiko-contracts/src/git-delivery-policy.test.ts b/packages/keiko-contracts/src/git-delivery-policy.test.ts index a1985287b..32e449144 100644 --- a/packages/keiko-contracts/src/git-delivery-policy.test.ts +++ b/packages/keiko-contracts/src/git-delivery-policy.test.ts @@ -44,6 +44,11 @@ function repoPack(partial: Partial): GitDeliveryRepoP } describe("isGitDeliveryPolicyRule", () => { + it("rejects a rule whose actionKind is not a known GitDeliveryActionKind", () => { + expect(isGitDeliveryPolicyRule({ actionKind: "bogus", decision: "allowed" })).toBe(false); + expect(isGitDeliveryPolicyRule({ actionKind: "warp-drive", decision: "blocked" })).toBe(false); + }); + it("accepts each well-formed decision shape", () => { expect(isGitDeliveryPolicyRule({ actionKind: "push", decision: "allowed" })).toBe(true); expect(isGitDeliveryPolicyRule({ actionKind: "push", decision: "blocked" })).toBe(true); @@ -287,6 +292,18 @@ describe("policy-pack parsers", () => { } }); + it("parseGitRepoPolicyPack rejects a rule whose actionKind is not a known action kind", () => { + const result = parseGitRepoPolicyPack({ + schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, + repoId: "r", + rules: [{ actionKind: "bogus", decision: "allowed" }], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.includes("invalid GitDeliveryPolicyRule"))).toBe(true); + } + }); + it("parseGitRepoPolicyPack rejects an invalid defaultRule", () => { const result = parseGitRepoPolicyPack({ schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, diff --git a/packages/keiko-contracts/src/git-delivery-policy.ts b/packages/keiko-contracts/src/git-delivery-policy.ts index afbe69c07..d539c7c3a 100644 --- a/packages/keiko-contracts/src/git-delivery-policy.ts +++ b/packages/keiko-contracts/src/git-delivery-policy.ts @@ -15,7 +15,11 @@ import type { GitDeliveryPolicyDecision, GitDeliveryProviderCapability, } from "./git-delivery.js"; -import { isGitDeliveryConstraint, isGitDeliveryProviderCapability } from "./git-delivery.js"; +import { + isGitDeliveryActionKind, + isGitDeliveryConstraint, + isGitDeliveryProviderCapability, +} from "./git-delivery.js"; export const GIT_DELIVERY_POLICY_SCHEMA_VERSION = "1" as const; @@ -186,7 +190,7 @@ function isRuleDecision(value: unknown): value is GitDeliveryRuleDecision { export { isGitDeliveryConstraint, isGitDeliveryProviderCapability }; function isActionKind(value: unknown): boolean { - return isNonEmptyString(value); + return isGitDeliveryActionKind(value); } // A decision's per-decision required fields must validate. approval-gated requires a string-array diff --git a/packages/keiko-contracts/src/git-delivery-provider.test.ts b/packages/keiko-contracts/src/git-delivery-provider.test.ts index 7ad972a66..517fa8e94 100644 --- a/packages/keiko-contracts/src/git-delivery-provider.test.ts +++ b/packages/keiko-contracts/src/git-delivery-provider.test.ts @@ -12,6 +12,7 @@ import { isGitDeliveryProviderDescriptor, isGitDeliveryPullRequestState, isGitDeliveryPullRequestStatus, + isGitDeliveryRemoteTargetPolicy, } from "./git-delivery-provider.js"; describe("provider status guards", () => { @@ -225,4 +226,80 @@ describe("isGitDeliveryProviderDescriptor", () => { }), ).toBe(false); }); + + it("rejects a descriptor whose capabilities contain an unknown capability string", () => { + // "warp-drive" is not a GitDeliveryProviderCapability — the guard must reject it. + expect( + isGitDeliveryProviderDescriptor({ + schemaVersion: GIT_DELIVERY_PROVIDER_SCHEMA_VERSION, + providerInstanceId: "inst-1", + capabilities: ["warp-drive"], + }), + ).toBe(false); + }); + + it("accepts a descriptor with the known capability 'branch-protection'", () => { + expect( + isGitDeliveryProviderDescriptor({ + schemaVersion: GIT_DELIVERY_PROVIDER_SCHEMA_VERSION, + providerInstanceId: "inst-1", + capabilities: ["branch-protection"], + }), + ).toBe(true); + }); +}); + +describe("isGitDeliveryRemoteTargetPolicy", () => { + it("accepts a valid remote target policy with typed exact and prefix patterns", () => { + expect( + isGitDeliveryRemoteTargetPolicy({ + allowedPushTargetPatterns: [ + { matchKind: "exact", value: "main" }, + { matchKind: "prefix", value: "release/" }, + ], + forcePushGloballyDenied: true, + }), + ).toBe(true); + }); + + it("accepts an empty allowedPushTargetPatterns (no restriction)", () => { + expect( + isGitDeliveryRemoteTargetPolicy({ + allowedPushTargetPatterns: [], + forcePushGloballyDenied: false, + }), + ).toBe(true); + }); + + it("rejects a policy whose patterns contain a raw string instead of a typed pattern", () => { + expect( + isGitDeliveryRemoteTargetPolicy({ + allowedPushTargetPatterns: ["main"], + forcePushGloballyDenied: false, + }), + ).toBe(false); + }); + + it("rejects a policy whose pattern has a bad matchKind", () => { + expect( + isGitDeliveryRemoteTargetPolicy({ + allowedPushTargetPatterns: [{ matchKind: "glob", value: "main*" }], + forcePushGloballyDenied: false, + }), + ).toBe(false); + }); + + it("rejects a policy whose forcePushGloballyDenied is not a boolean", () => { + expect( + isGitDeliveryRemoteTargetPolicy({ + allowedPushTargetPatterns: [], + forcePushGloballyDenied: "yes", + }), + ).toBe(false); + }); + + it("rejects a non-object value", () => { + expect(isGitDeliveryRemoteTargetPolicy(null)).toBe(false); + expect(isGitDeliveryRemoteTargetPolicy("policy")).toBe(false); + }); }); diff --git a/packages/keiko-contracts/src/git-delivery-provider.ts b/packages/keiko-contracts/src/git-delivery-provider.ts index 88b3ce02e..6d2d275d4 100644 --- a/packages/keiko-contracts/src/git-delivery-provider.ts +++ b/packages/keiko-contracts/src/git-delivery-provider.ts @@ -8,8 +8,16 @@ // // Imports ONLY from ./git-delivery.js (GitDeliveryMergeBlockReason, GitDeliveryProviderCapability). -import type { GitDeliveryMergeBlockReason, GitDeliveryProviderCapability } from "./git-delivery.js"; -import { isGitDeliveryMergeBlockReason } from "./git-delivery.js"; +import type { + GitDeliveryBranchPattern, + GitDeliveryMergeBlockReason, + GitDeliveryProviderCapability, +} from "./git-delivery.js"; +import { + isGitDeliveryBranchPattern, + isGitDeliveryMergeBlockReason, + isGitDeliveryProviderCapability, +} from "./git-delivery.js"; export const GIT_DELIVERY_PROVIDER_SCHEMA_VERSION = "1" as const; @@ -78,8 +86,9 @@ export interface GitDeliveryPullRequestState { export interface GitDeliveryRemoteTargetPolicy { // Branch name patterns the remote accepts as push targets. Empty array means all branches are - // accepted (no restriction). - readonly allowedPushTargetPatterns: readonly string[]; + // accepted (no restriction). Typed as GitDeliveryBranchPattern[] (exact/prefix) — raw strings + // are rejected at the boundary to preserve AC3 structured-data guarantee. + readonly allowedPushTargetPatterns: readonly GitDeliveryBranchPattern[]; readonly forcePushGloballyDenied: boolean; } @@ -115,7 +124,7 @@ function isUndefinedOr(check: (v: unknown) => v is T): (v: unknown) => v is T } function isProviderCapability(value: unknown): boolean { - return isNonEmptyString(value); + return isGitDeliveryProviderCapability(value); } // ─── Guards ────────────────────────────────────────────────────────────────────── @@ -198,3 +207,14 @@ export function isGitDeliveryProviderDescriptor( value.capabilities.every(isProviderCapability) ); } + +export function isGitDeliveryRemoteTargetPolicy( + value: unknown, +): value is GitDeliveryRemoteTargetPolicy { + return ( + isRecord(value) && + Array.isArray(value.allowedPushTargetPatterns) && + value.allowedPushTargetPatterns.every(isGitDeliveryBranchPattern) && + typeof value.forcePushGloballyDenied === "boolean" + ); +} diff --git a/packages/keiko-contracts/src/index.test.ts b/packages/keiko-contracts/src/index.test.ts index 069aae66d..0f612bca4 100644 --- a/packages/keiko-contracts/src/index.test.ts +++ b/packages/keiko-contracts/src/index.test.ts @@ -150,6 +150,7 @@ import { GIT_DELIVERY_EXECUTION_OUTCOMES, GIT_DELIVERY_MERGE_BLOCK_REASONS, isGitDeliveryActionKind, + isGitDeliveryRemoteTargetPolicy, gitDeliveryDefaultRiskClass, evaluateGitPolicy, parseGitDeliveryActionEnvelope, @@ -574,6 +575,7 @@ describe("keiko-contracts package surface", () => { // Value-level functions are reachable. expect(typeof isGitDeliveryActionKind).toBe("function"); + expect(typeof isGitDeliveryRemoteTargetPolicy).toBe("function"); expect(typeof gitDeliveryDefaultRiskClass).toBe("function"); expect(typeof evaluateGitPolicy).toBe("function"); expect(typeof parseGitDeliveryActionEnvelope).toBe("function"); diff --git a/packages/keiko-contracts/src/index.ts b/packages/keiko-contracts/src/index.ts index b41b56f68..5f19ada0c 100644 --- a/packages/keiko-contracts/src/index.ts +++ b/packages/keiko-contracts/src/index.ts @@ -1522,4 +1522,5 @@ export { isGitDeliveryMergeReadiness, isGitDeliveryPullRequestState, isGitDeliveryProviderDescriptor, + isGitDeliveryRemoteTargetPolicy, } from "./git-delivery-provider.js"; From 401b08a84c8c92de4c559c4daefff9c987a9bd56 Mon Sep 17 00:00:00 2001 From: Oliver Scharkowski <59687448+oscharko@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:17:29 +0200 Subject: [PATCH 03/16] feat(git-delivery): governed Git mutation execution kernel (Refs #472) (#1509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the deterministic preflight and mutation orchestration kernel for governed local Git writes (Issue #472, Epic #470), consuming the #471 contracts. - Lifecycle orchestrator (runGitMutation): resolve, preflight, preview, policy, execute, result — the single execution authority over local mutation kinds. - Deterministic preflight evaluators over a content-free worktree snapshot, with typed findings (blocking/advisory severity; user-actionable/internal remediation); idempotent reruns by construction. - Narrow local Git adapter: a typed port with NO generic exec method, a closed governed command table, a dedicated allowlist, and pure argv builders with flag-injection guards. The Node adapter runs plans through the existing keiko-tools no-shell spawn boundary. - Structured failure taxonomy (policy-block / preflight-block / execution-failure / provider-failure / recovery-required) consumable without string parsing. - Idempotency journal (records successes only) and safe-retry semantics. - ADR-0059 + docs; barrel exports + surface pins; ./internal/git-mutation subpath. The read-only terminal git baseline is preserved and machine-checked complementary to the governed write surface. Remote/provider execution (push/PR/merge) is deferred to #476-#478 behind a separate gateway. Refs #472 Co-authored-by: Claude Opus 4.8 --- ...-governed-git-mutation-execution-kernel.md | 222 +++++++ docs/adr/README.md | 85 +-- .../governed-git-execution-kernel.md | 149 +++++ packages/keiko-tools/package.json | 4 + .../src/git-mutation-adapter.test.ts | 206 ++++++ .../keiko-tools/src/git-mutation-adapter.ts | 276 ++++++++ .../src/git-mutation-node.integration.test.ts | 243 +++++++ .../keiko-tools/src/git-mutation-node.test.ts | 85 +++ packages/keiko-tools/src/git-mutation-node.ts | 190 ++++++ .../src/git-mutation-orchestrator.test.ts | 453 +++++++++++++ .../src/git-mutation-orchestrator.ts | 624 ++++++++++++++++++ .../src/git-mutation-preflight.test.ts | 289 ++++++++ .../keiko-tools/src/git-mutation-preflight.ts | 336 ++++++++++ .../src/git-mutation-taxonomy.test.ts | 132 ++++ .../keiko-tools/src/git-mutation-taxonomy.ts | 175 +++++ packages/keiko-tools/src/index.test.ts | 91 +++ packages/keiko-tools/src/index.ts | 70 ++ 17 files changed, 3588 insertions(+), 42 deletions(-) create mode 100644 docs/adr/ADR-0059-governed-git-mutation-execution-kernel.md create mode 100644 docs/git-delivery/governed-git-execution-kernel.md create mode 100644 packages/keiko-tools/src/git-mutation-adapter.test.ts create mode 100644 packages/keiko-tools/src/git-mutation-adapter.ts create mode 100644 packages/keiko-tools/src/git-mutation-node.integration.test.ts create mode 100644 packages/keiko-tools/src/git-mutation-node.test.ts create mode 100644 packages/keiko-tools/src/git-mutation-node.ts create mode 100644 packages/keiko-tools/src/git-mutation-orchestrator.test.ts create mode 100644 packages/keiko-tools/src/git-mutation-orchestrator.ts create mode 100644 packages/keiko-tools/src/git-mutation-preflight.test.ts create mode 100644 packages/keiko-tools/src/git-mutation-preflight.ts create mode 100644 packages/keiko-tools/src/git-mutation-taxonomy.test.ts create mode 100644 packages/keiko-tools/src/git-mutation-taxonomy.ts diff --git a/docs/adr/ADR-0059-governed-git-mutation-execution-kernel.md b/docs/adr/ADR-0059-governed-git-mutation-execution-kernel.md new file mode 100644 index 000000000..3209a2b03 --- /dev/null +++ b/docs/adr/ADR-0059-governed-git-mutation-execution-kernel.md @@ -0,0 +1,222 @@ +# ADR-0059: Governed Git Mutation Execution Kernel + +## Status + +Proposed + +## Context + +Epic #470 turns Keiko's read-only relationship to Git into a governed, end-to-end delivery platform. +Issue #471 (ADR-0058) delivered the typed contract surface: action kinds, a risk taxonomy, the +lifecycle envelope, policy packs with a deterministic `evaluateGitPolicy`, and provider-neutral +interfaces. Those are pure data and validators in the `keiko-contracts` leaf — they describe _what a +governed action is_, not _how it executes_. + +Issue #472 is the next child: build the execution and preflight kernel that consumes those contracts +and actually drives governed local Git writes. The product value is not raw Git coverage; it is +**predictable authority and failure behavior** — every mutation must follow one repeatable path with +machine-readable outcomes, and local Git write authority must never widen back into generic shell +access. + +Four forces shape the design: + +**Force 1 — One repeatable lifecycle.** Every mutation must resolve content-free inputs, run +deterministic preflight, produce a content-free preview, evaluate policy, and only then execute and +emit a structured result. Callers (approval UX, evidence ledger, recovery) must read where the +lifecycle halted and why without parsing strings. + +**Force 2 — No generic shell fallback.** ADR-0018 keeps the terminal `git` allowlist read-only: +`isTerminalCommandAllowed` denies every mutating subcommand. The new write surface must not be +reachable by widening that allowlist or by any "run this git command" escape hatch. It must flow +through a narrow, typed adapter whose every invocation is a fixed argv built from typed operands. + +**Force 3 — Determinism for preflight.** Preflight must be reproducible: the same repository state +always yields the same findings, so a rerun is idempotent and a caller can distinguish an actionable +user fix from an internal fault. + +**Force 4 — Boundary reuse, not a parallel path.** ADR-0019 designates `keiko-tools` as the single +no-shell spawn boundary (env isolation, deny-by-default allowlist, redaction, cancellation). The Git +adapter must execute through that boundary, never open a parallel `child_process` path. + +### Scope boundary (Issue #472) + +In scope: the orchestrator, deterministic preflight evaluators, the narrow local Git adapter with no +generic fallback, the structured failure taxonomy, and retry/idempotency semantics. Out of scope +(later children): end-user approval UI (#473), the evidence ledger and audit export (#474), +productized branch/commit UX (#475), publish/push orchestration (#476), the PR provider (#477), and +merge governance (#478). + +## Decision + +We introduce five modules in `packages/keiko-tools/src/`, forming a one-directional dependency DAG +(`taxonomy` and `preflight` depend only on the contract leaf; `adapter` adds the command table; +`orchestrator` composes them; the Node `git-mutation-node` adapter carries the spawn effect on the +`./internal/git-mutation` subpath): + +1. **`git-mutation-taxonomy.ts`** — the orchestration vocabulary: the ordered lifecycle phases, the + five failure categories, the lifecycle status union, and total mappings from the contract's + execution-error codes to categories. +2. **`git-mutation-preflight.ts`** — a content-free `GitWorktreeSnapshot` and pure per-kind + evaluators producing typed findings. +3. **`git-mutation-adapter.ts`** — the narrow `GitLocalMutationAdapter` port, the closed governed + command table, the dedicated allowlist, and the pure argv builders. +4. **`git-mutation-orchestrator.ts`** — the single execution authority that drives the lifecycle. +5. **`git-mutation-node.ts`** — the Node adapter that runs governed plans through the keiko-tools + spawn boundary (internal subpath). + +### D1 — The lifecycle orchestrator (AC1) + +`runGitMutation(request, deps)` is the single execution authority. It advances one repeatable path: +**resolve → preflight → preview → policy → execute → result**. The descriptive +`GitDeliveryActionEnvelope` is always populated through the policy phase — it is the contract artifact +preview and evidence consumers read — while two enforcement gates decide whether execution proceeds: + +- **Gate 1 (preflight):** a blocking preflight finding halts before policy enforcement and execution. +- **Gate 2 (policy + approval):** policy must permit, and any required approval must be valid and + unexpired. + +The returned `GitMutationLifecycleResult` carries the envelope, a `GitMutationOutcome`, the +`phaseReached` (where enforcement halted, or `result` when complete), and the full preflight report. +The orchestrator is deterministic given its injected dependencies (snapshot, clock, id generator, +adapter) and performs no IO itself: Git execution lives behind the injected adapter, so the +orchestrator is unit-testable with a fake adapter and never opens a parallel `child_process` path +(Force 4). + +The orchestrator's command union is the **local mutation kinds** — `branch-create`, `stage`, +`unstage`, `commit`, `abort`, `recovery`. Remote and provider kinds (`push`, `pr-create`, +`pr-update`, `merge`) are part of the shared contract and are classified by preflight and policy, but +their orchestrated execution is delivered by later slices that extend the union and register their +executors. Modeling the union as local-only is a typed scope boundary, not a placeholder: no runtime +"unsupported kind" branch is needed because the type only contains executable kinds. + +### D2 — Deterministic preflight (AC2) + +Preflight is a pure function over a content-free `GitWorktreeSnapshot` (counts, flags, and +branch/remote names only — no paths, diffs, or command output). Per-kind evaluators cover the +conditions Issue #472 enumerates: worktree state, detached HEAD, branch existence, upstream +readiness, untracked-file impact, commit-policy readiness, remote reachability, and in-progress +operations. + +Each finding carries a typed `code`, a contextual `severity` (`blocking` halts; `advisory` informs), +and an intrinsic `remediation` (`user-actionable` vs `internal`). The remediation split is the AC2 +distinction: a caller can route "stage a file" or "configure an upstream" differently from a kernel +construction fault, with no message parsing. Because the evaluator is pure over `(inputs, snapshot)`, +reruns are byte-identical — preflight reruns are idempotent by construction (Force 3). + +### D3 — The narrow local adapter, no generic fallback (AC3) + +`GitLocalMutationAdapter` exposes one typed method per local kind and **no** method that accepts an +arbitrary argument vector. That absence is the AC3 guarantee: there is no path from the kernel to +"run this git command string." Each method builds a fixed argv plan from the pure builders; operands +are validated (no NUL, no flag-injection via a leading `-` on refs) and file pathspecs are placed +after a `--` sentinel so a path can never be reinterpreted as an option. + +The Node adapter runs plans through the single keiko-tools spawn boundary (`runCommand`) with a +**dedicated** `GIT_MUTATION_COMMAND_RULES` allowlist that permits only the governed mutation +subcommands and denies global config / cwd-shifting / code-execution flags. This rule set is separate +from both the read-only terminal policy and the harness default. The two surfaces are complementary +and machine-checked: every argv the adapter can produce is denied by `isTerminalCommandAllowed`, and +every governed subcommand is outside the read-only allowlists. No network subcommand (`push`, +`fetch`, `clone`) appears in the governed set — remote execution is a later slice behind a separate +gateway, never this local adapter. + +### D4 — Structured failure taxonomy (AC4) + +The kernel names five failure categories — `policy-block`, `preflight-block`, `execution-failure`, +`provider-failure`, `recovery-required` — as DATA, never inferred from a message. The +`GitMutationOutcome` discriminated union binds a status (`succeeded` / `approval-required` / +`blocked` / `failed` / `recovery-required`) to its category and payload, and the contract's closed +execution-error codes map to categories through a total table (e.g. `conflict` and +`precondition-failed` → `recovery-required`; `provider-rejected` / `network-failure` → +`provider-failure`; `timeout` / `internal-error` → `execution-failure`). A new error code forces a +compile error in the table rather than falling through to an untyped default. Approval UX, evidence +capture, and recovery logic consume these categories without string parsing. + +### D5 — Idempotency and safe retry + +Preflight, preview, and policy are pure and always safe to rerun. Execution is guarded by an optional +`GitMutationJournal` keyed by an `idempotencyKey`: a re-submitted request that already **succeeded** +returns its recorded result instead of mutating the repository twice. Only successes are recorded — a +failed or blocked action did not apply, so re-running it is the caller's intended retry, not a +double-apply. A non-zero git exit at execution time is classified `precondition-failed` (a +time-of-check/time-of-use gap against the live repository), which routes to `recovery-required`; a +partially-applied multi-step plan reports `partial` with attempted/succeeded unit counts. + +### D6 — Boundary preservation + +The read-only terminal baseline is untouched. `isTerminalCommandAllowed` still denies every mutating +`git` subcommand; the governed write surface adds a separate, narrower, typed path rather than +widening that allowlist (Force 2). Execution flows through the existing keiko-tools spawn boundary, so +env isolation, redaction, output capping, and cancellation apply to governed mutations exactly as to +every other tool (Force 4). No new architecture boundary, quality gate, or security control is +weakened. + +## Consequences + +### Positive + +- One execution authority with a uniform, machine-readable lifecycle and outcome model that later + slices (approval, evidence, recovery) consume without string parsing. +- Local Git write authority is structurally narrow: a closed command table, a dedicated allowlist, + and a port with no generic exec method, all machine-checked against the read-only baseline. +- Deterministic preflight makes reruns idempotent and lets callers separate user-actionable fixes + from internal faults. +- The orchestrator is pure over injected dependencies, so the AC5 scenarios (success, policy block, + preflight block, adapter failure, recovery-required) are unit-testable with fakes, complemented by + a hermetic real-git integration test of the Node adapter. + +### Negative + +- The orchestrator executes only the local mutation kinds in #472; remote/provider execution requires + later slices to register additional executors. This is an intentional scope boundary, but it means + the kernel is not yet a complete delivery path on its own. +- A non-zero git exit is classified coarsely as `precondition-failed` because the kernel deliberately + does not parse stderr. Finer-grained execution diagnostics are deferred to the evidence ledger + (#474), which can attach redacted detail without the kernel re-deriving severity from text. + +### Neutral + +- The Node adapter runs with inherited network (local mutations never egress), so it needs no + isolation backend; a caller may tighten the sandbox policy. +- The adapter supplies an empty HOME through the spawn boundary, so commit identity comes from the + repository's local config; richer commit-intent composition (author/identity) is #475. + +## Alternatives Considered + +### Alternative 1: A single `runGit(args)` adapter method + +Rejected. A generic argv method is exactly the "run this git command" escape hatch Force 2 forbids; a +widened terminal allowlist or a generic exec method would reopen unrestricted command execution. The +typed-method-per-kind port with a closed command table is the structural guarantee. + +### Alternative 2: Preflight that reads git directly + +Rejected. Embedding git reads in preflight would make it non-deterministic and untestable without a +repository, and would duplicate the spawn boundary. A pure function over an injected snapshot keeps +preflight deterministic and idempotent; the snapshot is produced by a read-only inspection port. + +### Alternative 3: Free-form error strings for failures + +Rejected. AC4 requires categories consumable without string parsing. A typed category union plus a +total error-code → category table makes classification deterministic and forces explicit handling of +every contract error code at compile time. + +### Alternative 4: Execute every contract kind now (including push/PR/merge) + +Rejected as out of scope. The epic sequences publish (#476), PR (#477), and merge (#478) after #472, +and they require credentials, remotes, and provider APIs that are explicit non-goals here. Modeling +the orchestrator command union as local-only keeps #472 focused while leaving a clean extension seam. + +## Related + +- ADR-0058: Governed Git delivery contracts (the contract surface this kernel consumes) +- ADR-0019: Modular Package Architecture (keiko-tools as the single spawn boundary; leaf rules) +- ADR-0018: Terminal allowlist (read-only Git baseline being preserved) +- ADR-0043: Enforced Execution Isolation (the spawn-boundary enforcement model reused here) +- Issue #472: Implement deterministic preflight evaluation and mutation orchestration (this ADR) +- Issue #470: Epic — governed end-to-end Git delivery +- Issues #473–#479: Later children that build on this kernel + +## Date + +2026-06-25 diff --git a/docs/adr/README.md b/docs/adr/README.md index 8e1d16604..09507b7dd 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -4,48 +4,49 @@ This page keeps only the product decisions needed by reviewers. It is not an imp ## Current Decisions -| Area | Decision | -| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Product surface | Keiko is delivered as an npm package with a local UI and CLI. | -| Runtime model access | Models are configured at runtime through an OpenAI-compatible gateway. | -| Local-first operation | The UI binds to loopback and stores runtime state locally. | -| User control | Keiko does not commit, push, open pull requests, merge, or apply patches without explicit local action. | -| Workspace boundary | Repository reads and writes are bounded by the selected project path. | -| Command boundary | Verification uses allowlisted commands without shell execution. | -| Patch safety | Generated patches are dry-run by default and must be reviewed before application. | -| Evidence | Supported surfaces write redacted local evidence for human review. | -| Credentials | API tokens are local secrets and are never logged, serialized, or returned to the browser. | -| Evaluation | Pilot decisions require offline thresholds plus human-reviewed live model runs. | -| Package architecture | [ADR-0019](ADR-0019-modular-package-architecture.md) defines the modular workspace package architecture while preserving one customer-facing npm product package. | -| Workspace tooling | [ADR-0020](ADR-0020-workspace-tooling-and-architecture-gate.md) operationalises ADR-0019: npm workspaces under `packages/*`, shared TypeScript project references, and the architecture gate. | -| Publish strategy | [ADR-0021](ADR-0021-publish-strategy-bundled-monorepo-product.md) keeps workspace packages internal and the root tarball self-contained via `bundleDependencies`. | -| Connected context privacy | [ADR-0022](ADR-0022-connected-context-privacy.md) pins the privacy contract for grounded answers and evidence retention. | -| Installable PWA architecture | [ADR-0024](ADR-0024-installable-pwa-architecture.md) defines the supported browser/platform model, manifest contract, and local-secret boundary for installability. | -| Forward-only 0.2.0 modular baseline | [ADR-0025](ADR-0025-forward-only-0-2-0-modular-baseline.md) records the live package topology, bundled runtime contract, and error-severity gate posture. | -| Workspace substrate | [ADR-0026](ADR-0026-workspace-substrate.md) locks the existing `useWorkspace` as the workspace editor, DOM React tree as the renderer, `View { zoom, x, y }` as the camera, and the `WindowsRegistry` + `registerWindowRender` as the object registry; rejects an independent canvas / graph substrate. | -| Workspace state ownership and persistence | [ADR-0027](ADR-0027-workspace-state-ownership.md) partitions workspace state into eight classes with one owner package and one storage backend each; defines the closed `PersistenceExpectation` set used by object descriptors. | -| Workspace commands, events, selection, undo/redo | [ADR-0028](ADR-0028-workspace-commands-undo.md) defines the typed `Command` record contract, the conflict-at-startup keyboard substrate, and the typed `Action` discriminated union that compile-time refuses undo of evidence / patches / verification / model-call records. | -| Workspace object registry and extension contract | [ADR-0029](ADR-0029-workspace-object-registry.md) keeps `WindowsRegistry.ts` as the taxonomy seam, adds a `WIN_META` sidecar table typed by `WorkspaceDescriptorMeta`, and adds a metadata validator that rejects inconsistent authority / trust / persistence declarations. | -| Workspace security, evidence, and trust boundaries | [ADR-0030](ADR-0030-workspace-security-evidence.md) records the current workspace trust-boundary rules and durable-state restrictions. | -| Memory vault encryption-at-rest | [ADR-0035](ADR-0035-memory-vault-encryption-at-rest.md) seals local memory CONTENT columns with AES-256-GCM (zero new deps), keeps index/scope metadata cleartext, and resolves the key via `KEIKO_MEMORY_KEY` > macOS Keychain > `0600` keyfile. | -| Hybrid grounding evidence budget | [ADR-0036](ADR-0036-hybrid-grounding-reciprocal-rank-fusion.md) replaces the structurally folder-biased dual budgets in `runHybridGroundedAsk` with a single shared evidence pool ranked by Reciprocal Rank Fusion (`k=60`) across both lexical and vector engines. | -| Design-to-code: color emission | [ADR-0039](ADR-0039-design-to-code-color-emission.md) threads `textColor`/`backgroundColor` from `IrNode` through `EmissionElement` to the HTML/CSS adapter with a depth-bounded background rule that prevents stacked opaque fills. | -| Design-to-code: heading hierarchy | [ADR-0040](ADR-0040-design-to-code-heading-hierarchy.md) adds an additive `headingLevel: 1–6` IR field detected from the `"(headlines)"` Figma sibling-marker convention; rejected font-size and name-pattern heuristics as empirically unsound. | -| Design-to-code: form landmarks | [ADR-0041](ADR-0041-design-to-code-form-landmarks.md) wraps input-bearing container subtrees in `
` landmarks at the adapter layer with no IR change; defers `