Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ migrations applied: ['001_core.sql', '002_approval_identities.sql', '004_approva
contract: ok
```

`init` creates the state directory, applies migrations, and verifies the runtime contract (required migrations, tables, and governance triggers, foreign keys on, WAL journal mode). It is idempotent — re-run it any time.
`init` creates the state directory, applies migrations, and verifies the runtime contract: required migrations, tables, and governance triggers, foreign keys on, WAL journal mode, and **every schema object the governance rules depend on**. The expected schema is derived by replaying the bundled migrations in memory, so a store whose triggers or unique indexes are missing or altered — or that carries extra indexes or triggers, or was migrated by a newer version — fails the contract instead of silently losing an invariant. Every command runs this check. It is idempotent — re-run it any time.

`quorumgit status` shows the store path, contract state, and row counts. If the store is missing or incomplete, every command exits non-zero: there is **one storage backend and no fallback**, by design.

Expand Down Expand Up @@ -127,7 +127,7 @@ QUORUMGIT_AGENT=agent-two quorumgit handoff accept 1
# remaining work: wire into CLI, integration tests
```

Creating a handoff releases the original claim but keeps its worktree; accepting transfers that same worktree to the new claim. When the claim has an active worktree, the handoff records the worktree's `HEAD` as the last commit; `--last-commit` is used when there is none (hub model, or a worktree removed by `doctor --repair`).
Creating a handoff releases the original claim but keeps its worktree; accepting transfers that same worktree to the new claim. The handoff records `--last-commit` as the continuation point when given (any commit spec, stored as the full OID), otherwise the active worktree's `HEAD`; a claim without an active worktree (hub model, or a worktree removed by `doctor --repair`) must pass `--last-commit`.

What just *didn't* happen, silently: a second agent claiming task 1 (**BLOCKED**), claiming another task on branch `feat/x` (**CONFLICTING**), or claiming a task whose scopes overlap `src/**` (**OVERLAPPING**, refused unless explicitly overridden and audited). While the handoff was open, the task, branch, and declared scopes were **reserved** for continuation until agent-two accepted.

Expand Down Expand Up @@ -176,7 +176,7 @@ The hook learns who is pushing from `QUORUMGIT_AGENT` in its own environment, wh

Accepting a handoff in the hub model prints `worktree: (none — create manually)`: there is no managed worktree to transfer, so the successor fetches the branch into its own clone and continues from the reported commit.

Checkpoints in the hub model take an explicit `--commit <oid>`. Continuation points are **verified, not trusted**: the commit must exist in the registered repository, and when the claimed branch exists, be reachable from it. A typo'd or fabricated OID is rejected.
Checkpoints in the hub model take an explicit `--commit <oid>` (any commit spec Git resolves — a full or abbreviated OID or a ref — recorded as the full OID). Continuation points are **verified, not trusted**: the commit must exist in the registered repository, and when the claimed branch exists, be reachable from it. A typo'd or fabricated OID is rejected.

## Content governance

Expand Down Expand Up @@ -289,9 +289,9 @@ The incumbent's checkout is not duplicated. When a claim supersedes an earlier c
| `quorumgit task done <task> [--note <n>] [--remove-worktree]` | Close a task for good — live claim holder only; releases the claim |
| `quorumgit claim <task> --branch <b> --scope <glob>… [--no-worktree] [--takeover] [--override-overlap] [--lease-hours <h>]` | Claim a task |
| `quorumgit renew <claim> [--lease-hours <h>]` | Extend a live, unexpired lease; expired claims must be acquired again |
| `quorumgit checkpoint <claim> [--commit <oid>] [--note <n>]` | Record verified progress |
| `quorumgit checkpoint <claim> [--commit <commit>] [--note <n>]` | Record verified progress |
| `quorumgit release <claim> [--remove-worktree] [--reason <r>]` | Release a claim; the task returns to `open` |
| `quorumgit handoff create <claim> --completed <c> --remaining <r> [--to <agent>] [--files-changed <f>]… [--blockers <b>]… [--validation <v>] [--last-commit <oid>]` | Hand work off (`--last-commit` only when the claim has no active worktree) |
| `quorumgit handoff create <claim> --completed <c> --remaining <r> [--to <agent>] [--files-changed <f>]… [--blockers <b>]… [--validation <v>] [--last-commit <commit>]` | Hand work off (from `--last-commit` when given, else the active worktree's HEAD) |
| `quorumgit handoff accept <id> [--lease-hours <h>]` | Continue handed-off work (addressee, or anyone if unaddressed) |
| `quorumgit handoff decline <id>` | Decline — addressee only; removes the retained worktree, and is refused if it has uncommitted changes |
| `quorumgit handoff cancel <id>` | Cancel — creator only; removes the retained worktree, and is refused if it has uncommitted changes |
Expand Down
23 changes: 16 additions & 7 deletions src/quorumgit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ def cmd_checkpoint(args, cfg) -> int:
commit = trees.head_commit(wt["path"])
cp_id = work.add_checkpoint(conn, args.claim_id, agent, commit,
note=args.note)
commit = work.checkpoint_commit(conn, cp_id)
conn.commit()
print(f"checkpoint {cp_id} at {commit}.")
return 0
Expand All @@ -394,12 +395,16 @@ def cmd_checkpoint(args, cfg) -> int:
def cmd_handoff_create(args, cfg) -> int:
agent = _agent(args, cfg)
with store.session(cfg) as conn:
wt = trees.active_worktree_for_claim(conn, args.claim_id)
last_commit = trees.head_commit(wt["path"]) if wt else args.last_commit
# An explicit --last-commit always wins; otherwise continue from the
# managed worktree's HEAD.
last_commit = args.last_commit
if not last_commit:
print("Provide --last-commit (no active worktree for this claim).",
file=sys.stderr)
return 1
wt = trees.active_worktree_for_claim(conn, args.claim_id)
if wt is None:
print("Provide --last-commit (no active worktree for this claim).",
file=sys.stderr)
return 1
last_commit = trees.head_commit(wt["path"])
record = {
"completed": args.completed,
"remaining": args.remaining,
Expand All @@ -414,6 +419,7 @@ def cmd_handoff_create(args, cfg) -> int:
handoff_id = handoff.create_handoff(
conn, args.claim_id, agent, record, to_agent=args.to
)
last_commit = handoff.get_handoff(conn, handoff_id)["record"]["last_commit"]
conn.commit()
print(f"handoff {handoff_id} created (last commit {last_commit}).")
return 0
Expand Down Expand Up @@ -468,9 +474,12 @@ def cmd_handoff_cancel(args, cfg) -> int:


def _operation_from_args(args) -> dict:
operation = json.loads(args.operation)
try:
operation = json.loads(args.operation)
except json.JSONDecodeError as exc:
raise gate.GateError(f"Operation is not valid JSON: {exc}") from exc
if not isinstance(operation, dict):
raise SystemExit("Operation must be a JSON object.")
raise gate.GateError("Operation must be a JSON object.")
return operation


Expand Down
9 changes: 6 additions & 3 deletions src/quorumgit/handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,12 @@ def create_handoff(
if not record.get(field):
raise HandoffError(f"Handoff record requires {field!r}.")
task = work.get_task(conn, claim["task_id"])
work.verify_commit(
task["repository_path"], record["last_commit"], branch=claim["branch"]
)
record = {
**record,
"last_commit": work.verify_commit(
task["repository_path"], record["last_commit"], branch=claim["branch"]
),
}

to_agent_id = get_agent(conn, to_agent)["id"] if to_agent else None
row = conn.execute(
Expand Down
28 changes: 27 additions & 1 deletion src/quorumgit/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import os
import subprocess
from pathlib import Path

Expand Down Expand Up @@ -96,6 +97,28 @@ def _repository_dict(conn: Connection, row) -> dict:
}


# Variables Git exports to hooks that select a repository. A lookup of a
# specific path must not inherit them, or every path resolves to the hook's
# repository.
_REPOSITORY_SELECTING_ENV = (
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_COMMON_DIR",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_QUARANTINE_PATH",
)


def path_scoped_git_env() -> dict[str, str]:
return {
key: value
for key, value in os.environ.items()
if key not in _REPOSITORY_SELECTING_ENV
}


def git_common_dir(path: str | Path) -> Path:
"""Return the canonical Git common directory for a repository path.

Expand All @@ -109,6 +132,7 @@ def git_common_dir(path: str | Path) -> Path:
capture_output=True,
text=True,
check=False,
env=path_scoped_git_env(),
)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
Expand Down Expand Up @@ -206,7 +230,7 @@ def add_repository(
).fetchone()
assert row is not None
repo_id = row[0]
for refname in protected_refs or []:
for refname in dict.fromkeys(protected_refs or []):
conn.execute(
"INSERT INTO protected_refs (repository_id, refname) VALUES (?, ?)",
(repo_id, refname),
Expand Down Expand Up @@ -428,6 +452,8 @@ def add_agent(
begin_immediate(conn)
if role != DEFAULT_ROLE:
require_operator(conn, actor, f"Registering a {role}")
if conn.execute("SELECT 1 FROM agents WHERE name = ?", (name,)).fetchone():
raise RegistryError(f"Agent is already registered: {name}")
row = conn.execute(
"INSERT INTO agents (name, role) VALUES (?, ?) RETURNING id", (name, role)
).fetchone()
Expand Down
91 changes: 83 additions & 8 deletions src/quorumgit/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@

from __future__ import annotations

import functools
import json
import re
import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
Expand All @@ -26,6 +28,14 @@
MINIMUM_SQLITE_VERSION = (3, 38, 0)
MIGRATION_SEPARATOR = "-- quorumgit-statement"

# Kept byte-identical to the statement existing stores were created with.
SCHEMA_MIGRATIONS_DDL = """
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL DEFAULT (unixepoch())
)
"""

REQUIRED_TABLES = (
"schema_migrations",
"repositories",
Expand Down Expand Up @@ -293,14 +303,7 @@ def migrate(target: Config | str | Path) -> list[str]:
applied: list[str] = []
try:
begin_immediate(conn)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL DEFAULT (unixepoch())
)
"""
)
conn.execute(SCHEMA_MIGRATIONS_DDL)
conn.commit()

done = {
Expand Down Expand Up @@ -339,6 +342,71 @@ def migrate(target: Config | str | Path) -> list[str]:
# ------------------------------------------------------------ contract check


def _normalized_sql(sql: str | None) -> str | None:
"""Schema SQL with formatting-only whitespace removed."""
if sql is None:
return None
return re.sub(r" ?([(),;]) ?", r"\1", " ".join(sql.split()))
Comment thread
Deep-Sixed marked this conversation as resolved.
Resolved


def _schema_objects(conn: Connection) -> dict[tuple[str, str], tuple[str, str | None]]:
"""Every user schema object as {(type, name): (table, normalized SQL)}."""
rows = conn.execute(
"SELECT type, name, tbl_name, sql FROM sqlite_master "
"WHERE name NOT LIKE 'sqlite_%'"
).fetchall()
return {(row[0], row[1]): (row[2], _normalized_sql(row[3])) for row in rows}


@functools.lru_cache(maxsize=1)
def _reference_schema() -> dict[tuple[str, str], tuple[str, str | None]]:
"""The schema this QuorumGit build expects: every migration replayed in memory.

Deriving the fingerprint from the migrations themselves keeps it exact
without a hand-maintained list: every table, index (including the partial
unique indexes that enforce single ownership and single live approvals)
and trigger must be present with the same definition.
"""
conn = sqlite3.connect(":memory:")
try:
conn.execute(SCHEMA_MIGRATIONS_DDL)
for mig in _migration_files():
for statement in _migration_statements(mig.read_text(encoding="utf-8")):
conn.execute(statement)
return _schema_objects(conn)
finally:
conn.close()


def _describe(keys: set[tuple[str, str]]) -> str:
return ", ".join(f"{kind} {name}" for kind, name in sorted(keys))


def _verify_schema_objects(conn: Connection) -> None:
expected = _reference_schema()
actual = _schema_objects(conn)
missing = set(expected) - set(actual)
changed = {
key for key in set(expected) & set(actual) if expected[key] != actual[key]
}
# Extra tables are inert data; an extra index or trigger can change what
# governance writes succeed or what they do, so it is a violation.
unexpected = {key for key in set(actual) - set(expected) if key[0] != "table"}
problems = []
if missing:
problems.append(f"missing {_describe(missing)}")
if changed:
problems.append(f"altered {_describe(changed)}")
if unexpected:
problems.append(f"unexpected {_describe(unexpected)}")
if problems:
raise ContractViolation(
"Store schema does not match this QuorumGit version: "
+ "; ".join(problems)
+ ". Governance invariants cannot be guaranteed."
)


def verify_contract(target: Config | Connection | str | Path) -> None:
"""Fail loudly unless the local store satisfies the runtime contract."""
owned = isinstance(target, (Config, str, Path))
Expand Down Expand Up @@ -376,6 +444,12 @@ def verify_contract(target: Config | Connection | str | Path) -> None:
f"Missing required migrations: {sorted(missing_migrations)}. "
"Run `quorumgit init` to apply migrations."
)
unknown_migrations = applied_migrations - required_migrations
if unknown_migrations:
raise ContractViolation(
f"Store has migrations this QuorumGit does not know: "
f"{sorted(unknown_migrations)}. It was written by a newer version."
)

missing = set(REQUIRED_TABLES) - tables
if missing:
Expand All @@ -395,6 +469,7 @@ def verify_contract(target: Config | Connection | str | Path) -> None:
raise ContractViolation(
f"Missing required governance triggers: {sorted(missing_triggers)}."
)
_verify_schema_objects(conn)

foreign_keys = conn.execute("PRAGMA foreign_keys").fetchone()[0]
journal_mode = str(conn.execute("PRAGMA journal_mode").fetchone()[0]).lower()
Expand Down
Loading
Loading