~bigbes/sr-ht-spec

8219ede1 — Eugene Blikh 13 days ago
feat(web,service): the owner mints and revokes agent tokens in a browser

Issuing a credential required SSH to the host, which made the remote
agent write plane unusable from anywhere else: to hand an agent a token
the owner had to be at the machine. /tokens is that page — list, mint,
revoke — behind the same owner-only gate and same-origin guard as
approve/reject.

The mint is owner-only, and that rule is what revocation depends on: an
agent allowed to mint would survive having its own credential revoked by
issuing itself another, and "revoke the token" is the entire incident
response this design has. An agent asking for the page gets 403 rather
than the read plane's login redirect — it is authenticated already, so
bouncing it to meta would answer a question it did not ask.

The plaintext is rendered in the response to the POST rather than after a
redirect. A redirect would either drop the secret or carry it in a URL,
where it lands in history and in every proxy log on the way; the cost is
that a reload re-submits and mints a second token, which is one click to
revoke on that same page, whereas a lost token is not recoverable.

service.IssueAgentToken/ListAgentTokens/RevokeAgentToken hold the ACL and
the mint, and `specsrht token` now goes through them too, so the CLI and
the page cannot drift into two ideas of what issuing a token is.

spec-ejq.3
394285f4 — Eugene Blikh 13 days ago
chore(beads): file spec-rsb and spec-ovo, the two admin commands
90eb06ec — Eugene Blikh 13 days ago
feat(cmd): agent tokens and host-side proposals get admin commands

Two entry points were missing, and both left a deployment unable to do
the thing it exists for.

`token create|list|revoke` — db/ has had the whole agent-token lifecycle
since Phase 1, but nothing called it: no CLI, no page. A fresh instance
therefore had no credential for the agent write plane, which refuses an
anonymous caller by design, and the only way to mint one was an operator
hand-writing an INSERT with a sha256 hash. The plaintext is printed once
and never logged; only its hash is stored, and the listing deliberately
omits the hash so nobody mistakes it for the credential.

`doc propose ~owner/space <file>...` — the two agent write surfaces are
remote and so need a bearer token. When the operator and the documents
are already on the host, that token is ceremony: the process can open
Postgres and the bare repositories directly, so it constructs the agent
principal itself rather than resolving one from an agent_token row.
Provenance is not waived — --agent and --session are recorded exactly as
a remote agent's are, so `git log` cannot tell the two apart, and neither
can a reviewer. It calls service.Propose, so If-Match, the branch cut,
the trailers and the auto-merge gate stay spelled once.

Flags parse before, after and between the positionals: Go's flag package
stops at the first non-flag, which would make `doc propose ~bigbes/rfcs
spec.md --title x` drop --title and fail one layer down complaining
about a missing title rather than the flag it ignored.

spec-rsb, spec-ovo
46048cbc — Eugene Blikh 13 days ago
chore(beads): spec-ejq.2 re-index verified against repo.bigb.es
e849de2a — Eugene Blikh 13 days ago
chore(beads): close spec-ejq.2, CI publish is green on build #251
692636b1 — Eugene Blikh 13 days ago
chore(beads): spec-ejq.2 diagnosis resolved, the secret was never created
cc90b4a3 — Eugene Blikh 13 days ago
fix(web): a code fence whose language changed says so (spec-by6.4)

prosediff hashes a block's Info, so ```go becoming ```python pairs the two
fences as a modification — but Block.Lines holds a fence's contents without its
delimiters, so the line script came out entirely equal and every row rendered
as context. The page said the document changed and then showed nothing that
had, which is worse than either saying nothing or showing the change: the
reviewer looks for an edit that appears not to exist.

The fence's opening delimiter is not a row of this table and inventing a line
number for it would be a guess, so the change is stated as a marker row above
the fence's lines, in the same shape a move already uses. Only a code fence is
covered — Info also carries a list item's marker and a table's column count,
and neither is a language a reviewer would want announced.
d1621dca — Eugene Blikh 13 days ago
fix(prosediff): an equal run's separator comes from whichever side has one (spec-by6.5)

spans() read Span.Space off toks[0], and for an equal run those tokens come
from the old side. An equal run exists in both revisions at once, and the two
sides can disagree about what precedes it: a block that gains words at its head
has nothing before its first old token and the insertion before its first new
one. The script then said Space=false and a renderer joining the spans wrote
"{+in practice+}the storage layer" with the words run together — a defect the
reader would read as the author's, because nothing in the output says a
separator went missing.

Not reachable from the review page today: web/diffrows.go's sideSpans already
carries a dropped span's separator onto the next kept one, and the per-line
merge never joins two spans across that boundary. It was wrong in the data all
the same, and the next consumer of the script would have inherited it.

Passing the flag into emit rather than deriving it also drops the
write-back that reached into out[len(out)-1] to clear a substitution's
separator, which would have edited the wrong span had emit ever skipped an
empty run.
f42f81ca — Eugene Blikh 13 days ago
feat(web): line-numbered unified prose diff replaces the block cards (spec-by6.3.5)

The block-card renderer was reviewed against a live proposal and rejected:
"ADDED PARAGRAPH" outweighed the content on every row, every block carried
identical chrome, and on a new file the whole page is one change, so the cards
added noise and no signal. Two columns of digits say the same thing and then
get out of the way, which is what a gutter is for.

Selection is by line, anchoring is by block. Lines are what the cursor lands
on; block hashes are what survive a reflow. The web layer maps a selected line
range onto its enclosing prosediff block and stores the existing
core.CommentAnchor unchanged — service/, db/ and core/ do not move and the POST
wire format is untouched. The composer states which block it will anchor to
before anything is typed, so the indirection is visible rather than magic.

A line number is never guessed. A modified prose block goes through
prosediff.WordsByLine, whose ok=false contract is honoured with a paired
old/new region stating a line RANGE; a block rewritten past the similarity
threshold takes that path too. An equal block that was rewrapped states an old
number only for the lines the old revision really holds — equal line counts
were the first rule and were not proof, which a property test over 2800
generated document/edit pairs found within seventeen cases.

The markup is a table because prose wraps and a number has to stay on the first
visual line of the line it names. One rail ground behind both number tracks
with a single hairline against the content; the change tint starts at the sign
column so the gutter never reads as part of the change; heading rows pin
themselves as the section readout, replacing the per-hunk breadcrumb that only
restated a heading three rows above.

Folding and commenting both work with JavaScript off — the fold is a checkbox,
and every block keeps a visible composer. With the script in, that per-block
composer is hidden and reached by selecting lines instead, because sixteen
identical "comment on this block" rows are the chrome this port removes. A
composer holding typed text is never hidden by anything.

Two pre-existing prosediff faults are fixed here because line numbers are what
made them visible: a thematic break reported line 1 for every rule in the
document, and a document whose entire content is "---" panicked in
splitFrontmatter.
658bae75 — Eugene Blikh 24 days ago
feat(prosediff): recover the source line each word edit sits on (spec-by6.3.5)

The review UI is moving to a line-numbered unified diff, which needs to know
which line a word-level change happened on. The differ does not keep that.
Tokenize drops whitespace — "\n" and " " both collapse to Token.Space — and
that is precisely what makes a rewrapped paragraph produce a byte-identical
token stream and therefore no diff at all. The property is load-bearing, so the
line is recovered here rather than retained there.

It is recoverable because the script is ordered: the equal and deleted runs
reproduce the old block's tokens in sequence, and the equal and inserted ones
the new block's. Walking each side in step with that side's re-tokenized lines
says which line every token belongs to, and a run crossing a line break is cut
at the boundary.

The script supplies only the operation per token; text and spacing come from
re-tokenizing the source line. Taking text from the spans instead drops
separators — Span.Space is false on an insertion that directly replaces a
deletion, because in a combined rendering the deletion before it carried the
space, and split onto one side that deletion is gone. Caught by a test:
"delta CHANGED zeta" rendered as "deltaCHANGED zeta".

Reports ok=false rather than guessing when a block's Lines and Text disagree
about token count. A caller that cannot split falls back to rendering the block
as one old/new pair labelled by line range: a wrong line number is worse than
an honest range, because it invites a comment onto text that was never there.

spec-by6.3.5
61515a57 — Eugene Blikh 24 days ago
chore(beads): file spec-ejq.2, CI publish blocked on missing apk-ci-s3 secret
c2dd1ef9 — Eugene Blikh 24 days ago
chore(beads): Phase 5b closed, spec-ar4 blocked on phoebe host access

Records the Phase 5b closures (spec-by6.3 and its four children) and the
spec-ar4 finding: the nav restart is still needed — git/meta/todo still show no
spec entry and spec.srht.bigb.es answers 200 — but there is no push-SSH route
to phoebe from this machine, so it needs running on the host.
472bcb1f — Eugene Blikh 24 days ago
feat(web): commentable prose diff with honest anchor state (spec-by6.3.3)

Reverses the diff view's founding rule. renderDocDiff skipped ChangeEqual
outright — "the review shows only what changed" — but any block of a proposed
document must be commentable, so unchanged blocks now render as collapsed,
dimmed context. Changed blocks keep their border, tint and full body, so the
page still reads as a diff at a glance rather than as a document dump. A
context block carrying a comment renders open. ChangeMoveIn now shows its text
too, since a comment control on invisible text is a control on nothing;
ChangeMoveOut stays a bare marker and is deliberately not commentable, because
the same paragraph is anchorable at its move-in position and two anchors for
one paragraph is the bug that avoids.

Every rendered block carries id="b-<16 hex>", hashed from the whole anchor
tuple. Not the page ordinal: an ordinal renumbers on any insertion above it, so
a saved link would silently scroll to a neighbouring paragraph, whereas
including the block hash makes a stale link resolve to nothing instead.

Threads are placed by anchor and by nothing else. Anything no rendered block
claims — an outdated anchor, an old-side anchor whose block the diff no longer
draws, a document the proposal no longer changes — is collected into a
page-level "comments that lost their anchor" area. Never dropped, never moved
onto a neighbour: a comment reads as authoritative about the block it sits
beside, so attaching it to the wrong one is worse than admitting it lost its
place. An edited anchor is drawn on its block and badged.

The comment form's anchor is built at submit time from the branch as it now
reads, through service.AnchorOf — hand-rolling the ordinal conversion here
would put the browser's comments on different blocks than the MCP tool's, which
is the one way two surfaces of one conversation disagree without either looking
broken. The form's block hash guards it: a block that moved while the page sat
open is a 409, not a comment attached to whatever took its place.

That hash is required rather than checked-when-present. Skipping the guard for
a form that omits it would let a later template refactor drop the hidden field
and disable the staleness check silently, with every test still green.

Authority is surfaced, not re-implemented: compose and resolve are the owner's
because service says so and ErrForbidden becomes a 403.

spec-by6.3.3
f82d90a4 — Eugene Blikh 24 days ago
feat(mcpsrv): spec_comment closes the agent half of the review loop (spec-by6.3.4)

An agent can now read the review threads on a proposal and reply to them. It
cannot open a thread or resolve one, and that is enforced by the type rather
than by the handler remembering: spec_comment is written against a narrow
Commenter interface naming only Threads, ReplyTo, GetProposal and ProposalDiff,
so service.CommentOn and service.ResolveThread are unreachable from it however
service/ later grows. An unresolved thread suppresses policy auto-merge, so an
agent able to open or resolve one would hold the gate that exists to hold its
own output back. Writer is now the union of Proposer and Commenter, one narrow
interface per write tool, and each handler takes only its own half.

Every listed thread carries its anchor state, resolved against the branch as it
stands now rather than as it stood when the comment was written — often the
same agent has revised it since. An agent told only "fix this paragraph", with
no signal that the critique no longer describes any block, edits the wrong
thing. The tool description spells out what anchored/edited/outdated mean and
says plainly that replying does not close a thread, so an agent answers the
critique and pushes a revision instead of replying and waiting.

Replying requires the proposal as well as the thread. A thread id is a global
integer and service.ReplyTo needs nothing else, so a mistyped id would post a
reply onto a stranger's proposal, out of sight of the agent that wrote it; the
membership check reuses the threads already read for the ACL and costs nothing.

Reads stay on the uniform owner+agents gate rather than being narrowed to the
proposal an agent authored. Agent identity is self-declared in X-Agent headers
and all agents share one token, so an authorship check would constrain a string
the caller picks — stricter on paper than the read plane it sits in, and
enforcing nothing.

No wiring change was needed outside this package: main.go already passes
Write: svc, and *service.Service satisfies the widened Writer.

spec-by6.3.4
c6e5d667 — Eugene Blikh 24 days ago
feat(service): review threads and the policy auto-merge gate (spec-by6.3.2)

The comment API above db/, plus the rule that gives comments teeth: an
unresolved review thread suppresses policy auto-merge.

The gate matters because tryAutoMerge runs on every Propose, including an
agent's revision of an open proposal — so a proposal the owner stopped to
comment on could otherwise land unattended on the agent's next push. It gates
policy merges only. MergeHuman does not consult it: clicking approve is the
judgement the thread was asking for, and a comment nobody got round to
resolving must not be able to wedge a proposal shut.

Authority follows from that. The owner opens and resolves threads; an agent may
reply but may do neither, because both would hand the gate's control to the
thing it exists to hold back. An agent reply is not a resolution — answering a
critique is not the owner accepting the answer.

AnchorThreads lives here rather than in each surface, for the reason
Service.Archive does: the review page and the MCP tool must agree about whether
a comment still fits, and two surfaces each segmenting and matching would agree
only until one was changed. That is why service/ now imports prosediff. Each
document is segmented at most once per side however many threads hang off it.

AnchorOf is the other half: a surface offering "comment on this block" has a
document-global block ordinal, and the anchor needs the index within the block's
own heading path. Converting in one place is what stops the web form and the MCP
tool numbering blocks differently and putting their comments on different
blocks of the same document. A test asserts the two agree for every block of a
document.

A thread whose document is no longer among the proposal's changes — the agent
reverted it — is outdated, not dropped: a comment that silently vanished would
look like one that was never made.

Tested against a real Postgres 16, with a control test proving policy
auto-merge still fires without a thread, so the gate test cannot pass for the
wrong reason.

spec-by6.3.2
e183a11e — Eugene Blikh 24 days ago
feat(core,db): comment anchors and the comment table (spec-by6.3.1)

DESIGN.md held the comment schema back until the review UI existed, because a
schema is the expensive thing to get wrong here. The UI exists, so this lands
the anchoring model it specifies: (doc_id, heading_path, block_index,
block_hash), resolved content-first — the block's hash wherever it moved to,
then position under the same headings, and outdated when neither matches. A
comment that lost its place says so rather than being relocated to a best
guess, because a comment on the wrong paragraph reads as authoritative.

Two refinements the specified model left open:

block_index is the block's position WITHIN its heading path, not within the
document. prosediff numbers blocks document-globally, but the positional
fallback exists precisely for blocks whose content changed, and a global index
is destroyed by any insertion above it — so a global index would fail exactly
when it is needed.

A hash match is not unique. Documents repeat themselves ("TBD" under half the
headings), so the comment's own section wins outright and the nearest index
breaks what that does not. Without it, which duplicate a comment landed on
would depend on document order.

Anchor state is derived, never stored. A comment is not outdated in general, it
is outdated at a revision, and the proposal branch moves under it as the agent
revises; a column would cache a function of a moving input.

The table encodes the rest as constraints: a root carries the whole anchor and
a reply carries none of it (one predicate over all six columns, so a
half-written anchor is unwritable), agent provenance on proposal's rule, and
resolution only on a thread root.

Verified against a real Postgres 16, which caught what Go-level validation did
not: pq.Array sends a nil slice as SQL NULL, so a comment on a block before the
first heading tripped ck_comment_anchor and every document preamble was
uncommentable.

schema.sql keeps the comment table last, matching migration order — the
agreement test compares the two statement for statement. Its absent-list, which
required a design change rather than a quiet migration to add this table, is
the gate this commit passes through.

spec-by6.3.1
dd56e38c — Eugene Blikh 24 days ago
refactor(doc): one route from a revision to an Archive (spec-wcr #2, #4)

doc.Scan/DocumentSource were production-dead after service.Archive landed —
their only consumer was doc's own test fixture — and they were the seam that
made web's layering violation writable: a surface could reach past service/
into gitx and build its own archive. Deleted, so doc/ now owns no way to read
a revision and service.Archive is the single route from rev to Archive. The
fixture reads through ListDocuments + FromDocuments instead.

TestScanReportsGitErrors covered an unknown revision failing rather than
walking empty, which gitx did not test itself. Relocated there as
TestWalkOfAnUnknownRevisionFails rather than dropped.

linkHierarchy passed path.Dir(p.Path) where every other call site passes DirOf.
Unobservable today: the two differ only at the space root, and there the
section-proximity step is subsumed by the same-directory step above it, so "."
only ever skipped a lookup that had already answered. Verified by reverting and
re-running. Changed anyway — it stays unobservable only by coincidence of two
ranking rules — with a test pinning the invariant that `parent:` resolves to
whatever the same wikilink in the body resolves to.

spec-wcr
2dc6b717 — Eugene Blikh 24 days ago
chore(beads): enable Dolt auto-push to dolt.srht.bigb.es

Beads changes were only reaching the remote on a manual bd dolt push, so
issue state drifted from the tracker between sessions. bd has native
auto-push (debounced, pushes to the configured origin remote); it was
simply never enabled — sync.remote was set but dolt.auto-push was unset.

Enable it with an explicit 5m debounce rather than relying on the implicit
default. Verified end-to-end: a write fires "dolt auto-push: pushed
successfully", and a fresh dolt clone of the remote shows this session's
closed beads (spec-jjo, spec-mfm, spec-ejq.1) as closed.

Also commits the JSONL exports, which were stale from this session's work.
e97532cc — Eugene Blikh 24 days ago
test(db): guard OpenProposal branch SQL against core.ProposalBranch drift (spec-wcr #1)

OpenProposal allocates a proposal's id and branch in one INSERT, so it
cannot call core.ProposalBranch — it spells the name out in SQL as
BranchPrefix || id::text. That is a third derivation of the branch name
sharing only the prefix constant with the canonical function; a change to
how core.ProposalBranch formats the id would leave the SQL silently
producing a different name. Pin the coupling with a pure unit test so the
divergence fails loudly instead of in production.

Part of spec-wcr (loose ends). Pure test, no behavior change.
a0fa81b3 — Eugene Blikh 24 days ago
refactor(authn): one Principal.CanRead() for the read-plane ACL (spec-ejq.1)

graph's gate, web's mayRead and mcpsrv's Gate each hand-spelled
'IsOwner() || IsAgent()' — three copies of the read ACL, which graph's own
comment warned is how a corpus leaks when they drift. Define it once as
authn.Principal.CanRead and route all three through it.

coreauth.Derive keeps its own owner||agent test on purpose: it answers a
different question (is this an owner-backed identity to bridge to
AUTH_INTERNAL), and coupling it to the read ACL would misroute a future
read-only viewer kind to the owner's UserID.

Closes spec-ejq.1
Next