~bigbes/sr-ht-spec

ref: 3d811988f9960cf9057e09f30b59ceb71a7623c2 sr-ht-spec/db/proposal.go -rw-r--r-- 14.0 KiB
65eac3cf — Eugene Blikh 26 days ago
feat(service): write plane — Propose, Merge, ListProposals (Phase 3)

The service-layer orchestration for spec-zqb: the plane where the merge
model and the proposal state machine first run under real proposals
rather than on paper. Primitives (db proposal CRUD/merge, gitx
branch/commit/merge, authn provenance, core policy matcher) already
existed and were unit-tested; this composes them.

- Propose: the write plane, identical for REST and MCP. Row-first open
  (branch name derives from the serial id), branch cut, and a
  provenance-stamped commit — the agent authors, the owner commits, and
  the X-Agent-Session / X-Agent-Base trailers carry the rest into a
  plain git log. Adds to an existing proposal via ProposalID against its
  fixed base. Frontmatter/schema/id validation at propose time, mirroring
  the update hook the in-process agent write bypasses. Returns
  {proposal, url}.
- Auto-merge policy: a proposal whose every changed path matches the
  space's .spec.yml auto_merge lands immediately with ApprovalPolicy,
  best-effort — a stale or mixed-path proposal falls back to human review
  rather than failing the write.
- Merge / Reject: the state machine. Merge does the already-merged
  ancestry check before gitx's If-Match staleness (design: the two need
  different tests), the owner-signed merge commit, and the atomic row +
  document-registry flip. ErrStale / ErrAlreadyMerged / ErrForbidden map
  the 409/403 boundary.
- ListProposals / GetProposal / ProposalURL: the space-scoped read the
  graph Proposals port declared and left nil, plus the stable
  <origin>/~owner/space/p/<id> link.
- db.ListProposalsBySpace: the per-space, per-state listing.

Tested end to end against Postgres: open, auto-merge, mixed-path
fallback, stale-base 409, human merge, reject, add-to-existing, drifted
base. Surfaces (graph wiring, spec_propose, REST api/) fan out next.
29370c57 — Eugene Blikh 27 days ago
refactor: one derivation of a proposal's branch name, in core

db.ProposalBranch and gitx.ProposalBranch each spelled out "proposals/" +
id with different signatures and different error behaviour: db formatted
anything including 0, gitx refused a non-positive id. Two derivations of
one name is a proposal whose row and whose ref can disagree, which is
painful to trace and cheap to prevent.

core now owns ProposalPrefix and ProposalBranch(int64) (string, error).
gitx.ProposalPrefix and db.BranchPrefix are that constant, gitx wraps
core's error in ErrBadRev so its callers keep their failure class, and
db.ProposalBranch delegates — inheriting core's refusal of a non-positive
id, which is why its signature grew an error.
b217c7a7 — Eugene Blikh 27 days ago
refactor: move the reconciler's two deletes down to the layers that own them

The reconciler reached past its layer twice, because the primitives it
needed did not exist: a raw DELETE FROM proposal — the only SQL written
outside db/ — and a go-git RemoveReference under gitx's write lock. Both
move down, with no change in behaviour.

db.Store.DeleteOpenProposal keeps the guard in the statement, as
resolveProposal does, and distinguishes the two ways it can bite: a row
that is gone is ErrNotFound, a row that has been resolved is the new
ErrProposalNotOpen, which tells the reconciler "nothing to repair" apart
from "the repair no longer applies".

gitx.Repo.DeleteProposalBranch refuses anything outside proposals/* —
the only thing between a caller bug and a deleted approved branch — takes
the per-space write lock like every other write, and treats an
already-absent branch as success: the repair is a postcondition, and the
ref may legitimately vanish between the listing that found it and the
delete.
dc1e5d0c — Eugene Blikh 27 days ago
feat: Postgres schema, initial migration and the db/ query layer

The six tables of the design's Postgres schema — space, document_id,
proposal, agent_token, index_stamp, digest_mark — plus a typed query layer
over them. project and comment stay deliberately absent.

Two invariants are enforced in SQL rather than in Go, because a
check-then-write in Go has a window and this does not:

  - Global document ID uniqueness is the doc_id PRIMARY KEY, and the merge
    path's multi-row upsert guards its ON CONFLICT branch on the owning
    space, so a batch claiming another space's ID leaves that row untouched
    and surfaces as a CollisionError naming where the ID really lives.
  - Proposal transitions run as UPDATE ... WHERE state = 'open'; the current
    state is read back only to name it in the error. CHECK constraints make
    an unknown state, a merged row without an approval kind or merged_rev,
    and empty provenance unwritable by anything, including SQL that does not
    go through this package.

MergeProposal wraps the transition and the registry re-pointing in one
transaction: a merged proposal whose documents are still registered at their
pre-merge paths would break link resolution and the next staleness check.

Agent tokens store only a SHA-256 hash; the lookup boundary re-verifies with
subtle.ConstantTimeCompare and rejects revoked tokens distinctly.

Tests skip when SPECSRHT_TEST_PG is unset, so the pure-logic half — batch
duplicate detection, token comparison, argument guards, and the agreement
between schema.sql and migrations/0001_initial.sql — still runs with no
database available.