Changes touching this path
- ADR 0041 + spec: GitHub for loot — public multi-tenant forge (map #461 terminal)
81c89386 · dbf3dbe6… - ADR 0041: record the local-first, forge-optional invariant (map #479)
de036b99 · dbf3dbe6…diff - ADR 0041 + spec: change metadata is a second, repo-level visibility axis (#494)
8de4b27e · dbf3dbe6…diff - Correct the Internal-tier and Manifest facts where truth lives (#497, #498) (#505)
* Correct two false facts where truth lives: Internal is sealed, the Manifest is local (#497, #498)
Both tickets are editorial: prose that was trusted instead of checked against
the engine. Every claim below was re-verified against the source.
#497 — spec §1 called Internal a "plaintext object, not sealed". False:
`loot_codec::sealed::seal` encrypts unconditionally with AES-256-GCM for every
visibility; the tier selects only zstd (`compressed = matches!(vis,
Visibility::Internal)`). There is no plaintext-object path. What separates the
tiers is where the content key travels, so repo membership *is* the Internal
boundary — a gate over bytes the server cannot read, not one bolted over
plaintext it could. Corrected in the spec tier table (+ a new paragraph),
CONTEXT.md's Visibility glossary, and ADR 0041, where the same wording had
leaked into the tier list, the Vocabulary rationale, and a rejected alternative.
#498 — CONTEXT.md's Manifest entry claimed it "travels in bundles ... so every
peer has a complete audit trail". False: `BundleBody` is `{ changes, objs, keys,
attestations }`, with no manifest lane; the Manifest is written by
`apply_sealed_grant` and persisted by `save_to`, local-only. The trail is
per-peer and partial.
The error originates in ADR 0008, whose title asserts it and whose last
consequence promised a wire section that `git log -G manifest --
bundle_codec.rs` shows was never written. Amended in place rather than rewritten
— the decision is history; the amendment records that it was not built, and that
this is why the forge's `grant_log` has no wire producer (#484 supplies one).
Also fixed the same false claim still live in `manifest.rs`'s own doc comment,
and dropped the clause's "escrow entries" half: the plaintext escrow bundle
section was deliberately removed in format v5 (ADR 0027) because it shipped
plaintext keys.
Docs + one doc comment; no behavior change. cargo check --workspace clean;
loot-core + loot-codec 357 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fold in the #494 review findings the ticket widened #497 to cover (#497)
Eleven recording-hygiene and accuracy defects a two-axis review of d20d2b2c
found in the same three files, folded into this landing per the ticket comments.
None change a decision.
Hard:
1. ADR 0041 was amended in place with no marker and no #494 citation, so the
metadata subsection read as decided at acceptance. Added the repo's
established `> **Amended <date> (#nnn):**` form; #494 is now cited in all
three files.
2. The ADR had taken on DDL (`repo.metadata_public boolean NOT NULL DEFAULT
false`) it explicitly delegates to the spec. It now states the axis; the
column and type live only in the spec.
3. `vis_tag` was undefined in-repo and collided with a same-named wasm display
helper of a different type. Now "visibility tag" in prose, as the ADR had it.
Accuracy:
4. Dropped the ADR 0038 overclaim. That gate fires on a secret-shaped name
resolving world-readable by fallthrough, per path, at a signing verb; a
derived-metadata leak has neither path nor name shape, so it could never
fire. Kept as an analogy of accident *class*, explicitly not of mechanism.
5. The flag is consulted only for anonymous readers
(`access_class <> 'anon' OR r.metadata_public`, verified against #483), so
it widens access rather than narrowing it. All three files had implied it
and the access class were coequal inputs.
6. Recorded the invariant that makes the listTree corollary safe to build
from: tree rows store no Restricted id-set, and grant_log is the sole
answer to "who". Without it a builder could render the grantee set and leak
who has access on a metadata-public repo.
7. Replaced the undefined "barrier-view predicate" ("barrier" already means
ADR 0031 operation barriers) with a definition at its one buildable home.
Judgement:
8. Thinned the spec to normative statements; the ADR keeps the rationale, so
renaming the column at build time is one edit rather than three.
9. Gave the rejected repo-level Public/Private toggle its reciprocal note —
Alternatives sections get read standalone.
10. Moved Metadata visibility below Identity in CONTEXT.md, restoring the
antecedent its 17-line insertion had severed, and back-linked it from
Visibility. Citation now uses the dominant *(decided <date>, …)* form.
Also, from #484 via the same ticket: recorded the missing-producer pattern as a
standing ADR 0041 invariant beside local-first — loot's sync assumed a trusted
peer that already holds the whole store, so a forge must ask of every fact
whether the wire carries it. Five tables have hit this already; the sixth
should be anticipated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Git-Author: Connor Miller <53197564+Connor-Miller@users.noreply.github.com>
f8b890b5diff - ADR 0041: #501 is resolved, not a blocker (#497 follow-up) (#507)
The missing-producer invariant landed in be26664 listed
`burn_tombstone.signed_tombstone` as "(#501, a blocker)". #501 has since closed
on a second pass — its first entitlement rule ("an author of a change
referencing the oid") was withdrawn, because `ChangeNode.tree` is a full
manifest and `loot duplicate` copies trees address-for-address, so the predicate
meant nothing. The build moved to #503.
Factual staleness only; the invariant's substance is untouched.
Note for #504: the surrounding paragraph frames the pattern as a question about
the sync wire ("does the wire carry this?"), and #504 is a sixth instance that
framing cannot catch — its missing producers (`repo`, `tenant`,
`directory.username`, `default_position`) are account-layer state owned by the
web app, where the wire answer is "no, and it never should be". Widening the
invariant to "what produces this row?" is deliberately left to #504's
resolution rather than guessed at here.
Git-Author: Connor Miller <53197564+Connor-Miller@users.noreply.github.com>
0296965cdiff - Authenticate the purge lane: a purge is a signed request (#503)
A wire purge carried only an oid and yielded an unauthored tombstone, and
stow honored it before storing objects -- so any authenticated pusher could
destroy any oid across every tenant, needing no key, only the address.
ADR 0038 already called a purge event a request asking cooperating relays
and peers to destroy their copy. Cooperating meant nothing: loot honored
anyone. The signature now says who is asking; a per-receiver policy decides
whether to honor. Peers honor registered senders and quarantine strangers;
relays honor their push allowlist. Local burn is untouched and needs no key
-- only propagation requires a signature, so a keyless repo burns locally
and refuses to propagate, explicitly.
No global entitlement predicate exists: ChangeNode.tree is a full manifest
and loot duplicate copies a tree address-for-address, so authored-a-change-
referencing-this-oid is satisfiable by any cloner. Both withdrawn rules
have regression tests.
Format 9 to 10. A v10 reader parses a legacy purge lane and drops it, so no
unsigned request is honored while the rest of a v9 bundle still decodes; an
older client hard-fails on v10.
Destruction is structurally unreachable without verification: HonoredBurn
has a private field and authorize_burn is its only constructor.
Both halves are signed, with domain-separated schemes -- without tags a
maroon signature re-encodes byte-for-byte as a burn tombstone at path
length 31. Maroon entitlement is an exact Manifest grantor join, and a
grant only records a grantor when it actually installed a key, so a crafted
grant cannot plant one for content you already hold.
Closes #503.
913c5cc6 · dbf3dbe6…diff - Forge service skeleton: /ingest, the sync surface, and CLI forge remotes (#502)
A new `loot-forge` crate and binary, sibling to the relay and never a mutation
of it: `loot-net` is a dependency of `loot-cli`, so a storage driver in the
relay would ship inside the released CLI binary (ADR 0041:53-57). The ban runs
one way -- the forge may use the other crates; none of them may gain a driver.
Every endpoint is envelope-authenticated, where a relay authenticates only
/stow. The one declared exception is GET /info: a client reads the capability
probe before it knows how to authenticate.
POST /ingest = envelope_sign(bundle || head_declaration), one signature over
both, so a genuine bundle can never be replayed under someone else's head
claim. CAS first; nothing is written before the generation compare. A lost CAS
answers with a 409 BODY carrying the current generation and head set -- behind,
not bounced -- so the client re-carries with no extra round trip.
/stow keeps relay parity and moves no ref, and consumes no generation either,
since a client reads the generation once and then stows N batches. It DEFERS a
change whose objects have not all arrived; /ingest REFUSES one, because a
declared head whose objects are missing is a repo no pull can complete. That
asymmetry is what keeping /stow is for.
Bundles are filtered PER OBJECT by the caller's access class, closing the leak
the ticket names: bundle_impl ships a key for every ANYONE-granted object, i.e.
every Internal object. Change metadata rides the second #494 axis and gates the
whole walk -- a ChangeNode carries the full tree, so shipping one while
withholding its bytes still discloses every path, address, message and author.
Validation before any write: author signatures, and a tree whose visibility
contradicts its objects is rejected. vis_tag is derived from the SealedObject
and never trusted from the tree, which is what makes content-addressed upserts
safe.
Forge purge policy: burn iff burner == object.introduced_by, recorded at first
arrival as introducing.author else the envelope pubkey; maroon read off
grant_log, global and exact. Both are judged before any object is stored and
outside the CAS transaction.
Repos are pubkey-addressed at /k/<pubkey>/<repo>, auto-created on first push,
and may_push holds iff the signer owns the namespace. resolve_remote is
untouched -- still no default host. CORS from day one.
Storage sits behind three narrow traits with in-process reference
implementations the whole suite runs against; the Postgres DDL they mirror is
checked in at docs/sql/forge-schema.sql and its driver is #516.
CLI: /info capability detection, the live head declaration off the Liveness
view, and a refusal to push a lane -- v1 ingests the primary position only.
verify_authored_change moves to loot-codec so the forge runs the identical gate
without linking the fs-hardwired engine.
Recorded in ADR 0041, the spec and CONTEXT.md: the forge stores no content key
but a published one, because filing the ANYONE key lane would let the server
read every Internal object, and the keystone is that only Published is
server-readable. The cost is stated rather than hidden -- a fresh clone of your
own Internal content from a forge is ciphertext you cannot open until #488
lands.
Closes #502.
300e06ff · dbf3dbe6…diff - a publication survives a merge, an ingest and a resolve, so a catch-up stops silently reverting one (#783)
The reported symptom was consent fatigue: every capture in the repo refused with
"refusing to publish README.md", including changes touching nothing published.
The cause was the opposite of the symptom -- README was not published any more,
and the gate was correctly reporting a new @world transition on a publication
that had been reverted.
The ticket's hypothesis (an adopt/ferry fold re-sealing README from git-side
content) was not the mechanism. Reading the real graph, #750's own publication
change never published README at all, while it did publish both licenses. The
difference is that the licenses were NEW paths, so the snapshot seal minted them
@world, while README already existed and went through the merge -- where converge
picked between two addresses holding identical plaintext under identical
Visibility and its tie-break took the unpublished one.
@world is address-neutral, outside every signature and ignored by open,
key-travel and compression. That inertness is what made publication cheap; it is
also why nothing carries the marker structurally. It survives only where the code
writing a tree entry chooses to carry it, and four writers did not: converge, the
git bridge's ingest, resolve (so a bounce quietly unpublished the conflicted
path) and absorb's span rebuild. The snapshot seal learned this at #481 and
nothing generalized it, so Repo::put_sealed now is the generalization.
converge gains an is_published question on KeyOracle and compares publish-status
as part of a path's content, which makes the existing 3-way answer publish flips
correctly in both directions: a publication since the fork is an edit and wins
over a stale untouched side, and a deliberate unpublish is equally an edit and
still lands. On identical bytes with no base to judge from, the published side
wins -- widening was consented to once, and reverting it on no evidence is the
only outcome that loses something silently.
The other three carry, and carry ONLY. A re-seal never originates a publication:
ingest takes its policy from a git commit, which reaches loot without passing a
consent seam, so a break-glass commit adding a `published` rule seals plain and
leaves the transition for the capture gate to ask about in the usual place.
Sealing more tightly than the rule says is always safe; more loosely is what this
subsystem exists to prevent.
Ask 2 -- should an unpublish need consent? No. A prompt would make consent MORE
frequent, which is the failure the ticket is actually about: a gate that fires
routinely teaches operators and agents to pass publication flags reflexively. The
fix is fewer ways to unpublish, not another prompt, so dropping the rule is now
the only one. For the same reason the gate still asks the anchor OBJECT's marker
and not the anchor's .lootattributes: policy and marker disagree both when a
marker was lost and when a rule has just begun publishing a path, nothing there
can tell them apart, and trusting policy would turn the standing rule into a way
to publish unasked. A future marker-losing path would make the gate nag again,
and that is the failure worth having.
Ask 4 -- the forge's published index cannot disagree with main: this repo pushes
to relay.millerbyte.com and has never pushed to the forge, so the index holds no
README row. Had the push happened inside the window, it would have received the
unpublished object.
ADR 0041 gains a 2026-08-04 amendment; CONTEXT.md's Published entry records the
carry/originate split.
ec50222e · dbf3dbe6…diff - a forge push deposits the keys the forge itself drops: standing self-grants widen to Internal and embargoed-to-self at forge remotes, closing #502's second-machine cost (#1042)
f8edab5d · dbf3dbe6…diff - a relay's deposit stops standing in for the forge's: the push-time dedupe moves off the Manifest onto a per-remote deposit ledger, so relay-first Restricted custody reaches the forge inbox the browser reads (#1052)
499739af · dbf3dbe6…diff - the forge door stops being a quota stopgap: the push allowlist is recorded as a custody list, and the six places promising open multi-tenant push behind metering now name what actually shuts it (#1045)
a790ec8e · dbf3dbe6…diff - the docs gate themselves: a land refuses a link that resolves to nothing, five rotted ADR cross-links are fixed, every amended ADR warns in its status, and the artifact sweep retires with its producer
586e3260 · dbf3dbe6…diff - the forge stops holding lanes and holds proposals instead: #504 decision 3 recorded two objections and this ticket carried only the privacy one, but a hosted lane tip would also enter ref_head and reintroduce the multi-head ambiguity v1 exists without, so a hosted lane was the wrong noun rather than a deferred feature and both objections dissolve at once rather than trading off. ADR 0075 records the eight decisions - a proposal and not a lane, its own verb over its own route with bytes riding stow so ingest stays the one ref-moving endpoint, a stack named by its tip's durable change id with extent derived to the landed frontier and the base declared as a checked assertion, existence riding metadata_public paired with a propose-time disclosure because the owner's flag cannot consent for the contributor, three declared terminal states because deriving liveness from silence is exactly what #483 forbade, may_propose beside may_push rather than a widening of it, and provenance as two records that never merge - and the forge spec's contribution section, its swimlanes paragraph, ADR 0041 and CONTEXT.md are amended to match, with the approval flip that arrives when the author is not the lander handed to map #1014 rather than answered here (#1638)
6968491d · dbf3dbe6…diff - ADR 0099 records the move of loot from millerbyte.com to loot.build (map #2412): the site and one-liner at the apex with www redirecting, artifacts at dl.loot.build, the forge at forge.loot.build, security@loot.build through Cloudflare Email Routing with no catch-all, the relay not recreated, and the production Clerk instance on loot.build; and a hard cutover, the operator decision, in which the new names serve beside the old, a release carries them, every clone re-points its forge remote, and the old names are removed with no alias or redirect kept. it states what that breaks for anything made before it, read from the code: an opted-in old binary POSTs telemetry to the old name silently, so the old names must end NXDOMAIN rather than answered by the millerbyte.com wildcard, which would hand those reports to Vercel; old --help links; installers and manifests pinned to dl.millerbyte.com; the crates.io placeholders published with the old homepage, which only a new publish by the operator fixes; and it records the measured costs of the move (a 19.2 s first unlock on the new origin, no forge change because a signed request does not bind the host). ADRs 0037, 0041, 0045, 0049, 0061, 0064, 0066, 0067, 0074 and 0097, whose decisions name a loot host, each gain an amendment pointing here, and a banner where they had none; their bodies keep the names they were written with (#2414)
bf2242c5 · dbf3dbe6…diff
Renames are not followed. loot's tree maps a path to an address, so a rename is a delete and an add. This list is the history of the name, not of the bytes.