externals names two readings of a 404 — withdrawn, or access lost — and those are the two a locked document can have. Adoption by name has a third: a document that was never there under that name. Found in use, where a missing s reported the ambiguity instead of the typo. The tool was reporting an ambiguity it had the means to resolve: the by-name form knows the repository, so on a 404 it now lists the published surface and says which names exist. It claims that only when the listing succeeds — if listing fails too, the repository is unreachable and the original ambiguity is the honest answer, which is the same discipline as recording public and not-public rather than private. Not filed as a gap against externals. The third reading cannot occur where that document is speaking, which is check against a lock; it exists only at adoption, which is ours. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
56 KiB
Event log
Appended, newest last, never revised. A later entry supersedes an earlier one by saying so.
Entries tagged osprey were all considered in a single sphere of understanding.
2026-09-07 — the design lives here and not in a spec osprey
Decided: this log is the artifact of osprey. No specification document
is written, and the specimen that opened the round is discarded.
Believed to advance: every entry here carries a belief that could be shown wrong, so a reader can see which parts are load-bearing and which were guesses. A spec flattens all of it to equal confidence — which is how the specimen managed to be wrong, with a straight face, within a day of being written.
Belief that could be shown wrong: that a reader can build from a log. It is ordered by time and not by topic, so somebody arriving reads chronologically and holds the corrections in their head. Fine at fifteen entries and not at eighty. When a newcomer cannot build from it without a guide, the guide is the next thing to write — and not before.
2026-09-07 — freshness is a conditional request osprey
Decided: the tool records the publisher's ETag, verbatim and opaque, and
sends it back as If-None-Match. It never computes a hash of anything.
Believed to advance: externals requires it, and we now have the empirical
reason as well as the stated one. On gitea a raw file's ETag is its git blob
hash; on GitHub it is not. A design that compares a local hash to a remote
ETag works on exactly one host by coincidence.
Belief that could be shown wrong: that ETag is stable enough to lock on.
A proxy, a CDN, or a host that regenerates the header per request would produce
a change report where nothing changed. Untested against anything but gitea and
GitHub.
2026-09-07 — supersedes nothing: the path is for a person osprey
Decided: the origin URL is recorded in the lock, not derived from the stored path.
Believed to advance: the path \.loom/externals/<host>/<path>.md does not
round-trip. Measured: the document we hold as
git.hypertheory-labs.dev/loom/externals/externals.md is served from
/loom/externals/raw/branch/main/.loom/published/externals.md — the stored
path has dropped the route, the branch, and .loom/published/.
The third is not routing. publication.md makes .loom/published/ the whole
contract, and a tree that erases it cannot say whether a copy came from
somebody's published surface or from a file they may rename at will.
Belief that could be shown wrong: that people will still read the path as a location. If anybody writes code that parses it back into a URL, the path should stop looking like one.
2026-09-07 — 404 is unresolved, and that is no longer provisional osprey
Decided: check reports a 404 as unresolvable — the document was withdrawn
or we have lost access — and names both readings without picking one.
Believed to advance: over HTTP they are the same response, because a host that distinguished them would leak the existence of things you may not see.
This entry exists because the status of the behaviour changed and the code did
not. It began as a local workaround recorded in a .gaps.md against
externals, which listed 410 and not 404. externals has since added the
404 row, so the gap is closed and the facet is deleted at this reconciliation.
The workaround is not retired. It is now the specified behaviour. Unchanged in the code and entirely changed in status — and this log is the only place that says so. Somebody inheriting this will find the behaviour and go looking for the gap that justified it, and find nothing.
Belief that could be shown wrong: that reporting both readings is useful. If in practice it is always one of them, the report is a ritual — and that is worth knowing.
2026-09-07 — declined: check does not resolve the 404 over ssh osprey
Considered: the two readings are distinguishable over ssh — permission
denied against repository not found — and externals now says so. Demonstrated
here by accident: loom/settled returned 404 over HTTP while an ssh clone of
it succeeded.
Not done, because: it needs a key, a non-default port, and a second transport — and it would be fixing rather than reporting, which is the rule this design held all round.
Belief that could be shown wrong: that a person will do it. The ssh disambiguation is one command and it is in the document; if nobody ever runs it, the ambiguity was never actionable and reporting it was theatre.
2026-09-07 — declined: init osprey
Considered and rejected, not deferred.
Because: its temptation is to scaffold, and four empty directories assert four things nobody has decided. A file that carries no delta should not exist.
Belief that could be shown wrong: that starting is easy without it. If people repeatedly fail to start, the useful version creates one file and asks one question — and creating the tree is still not the feature.
2026-09-07 — declined: worktree, clone; and where a round lives osprey
Decided: the cart lives on the default branch; the work is isolated on a branch named for the cart.
Believed to advance: a cart is a venue and the work is an artifact. cart
says three states are visible in a listing, and a branch makes all three
invisible — a venue only visible if you know which branch to check out is not a
venue. Both dailies land on the default branch, one commit per exchange.
Declined: a worktree, and a separate clone. Both isolate concurrent working
directories, and cart says there is one live cart, ever. They solve a problem
the agreement says cannot exist.
Belief that could be shown wrong: that the dailies and the work have different audiences. If a daily needs to quote a file that only exists on the branch, the split makes the daily unreadable from where it lives, and the answer collapses to one branch for both.
2026-09-07 — publish is renamed reachable, and it earned its place osprey
Decided: the command is loom reachable <path> — one anonymous request
against a document in .loom/published/.
Believed to advance: the old name said the tool performs publication, which is the one thing the model says it never does. It performs a fetch with a different subject, which keeps it inside a fetcher and a comparator.
It was nearly cut for being a third kind of thing. Then it was validated three times in one afternoon, against loom's own repositories:
loom/settled 404 anon the page written to fix "settled is private"
jeffry/homelab-cluster 404 anon and bedrock's public starting page named it
as the better answer
loom/.loom 200 anon fetchable — but not where a reader lands
All three are now fixed. The third was not a reachable failure and is
recorded as the tool's boundary: reachable answers can anybody fetch this
and does not answer will anybody find it. The second is not testable by a
fetcher and a comparator, and trying is the first step in the tool becoming
something else.
Belief that could be shown wrong: that publication failures are common enough to need a command. Three for three is a small sample from one publisher in one week.
2026-09-07 — the ETag/blob coincidence is a migration aid osprey
Recorded so it is not rediscovered as a good idea.
With eight adopted documents and no locks, check was run by hand by hashing the
local copies and comparing to remote ETags — the exact thing the convention
forbids — and it worked, because on gitea the two coincide. It found
exactly one drifted document, correctly.
It is worth doing once, on gitea, to lock what was fetched by hand before the tool existed. It is not a mechanism.
The coincidence has now been found twice and rejected twice. The next person to notice it will think they have found the good idea again.
2026-09-07 — annotate to ask; write in your own file to assert osprey
Decided: responses between two presences are correspondence — each writes in their own file — and annotation is reserved for asking or challenging.
Believed to advance: the freeze is not "you cannot edit", it is "you cannot edit without resolving what was said". An annotation creates an obligation, which makes it the blocking form and correspondence the non-blocking one.
And the cost is practical: dissolving a multi-pass file is expensive, so the cost of annotating rises with the length of the thing annotated. Annotate early, or accept the cost when the question is worth blocking on.
Belief that could be shown wrong: this rule is in neither cart nor
annotating. It is our reading of their text, and cart is loom's — they
may say we have it wrong.
2026-09-07 — why this tool exists, in one story osprey
Gitea's ssh is on port 2222. I did not know that. I tried to clone, got
Permission denied (publickey), and spent three tool calls and a guess chasing it
as an authentication problem before finding the port.
The fact was published. jeffry/homelab-cluster, in .loom/published/gitea.md,
says:
A clone URL without the port will not work, and the failure looks like an authentication problem rather than a wrong port.
It named my failure before I had it, and I could not read it — the repository was not fetchable by anybody who is not its owner.
This happened inside the round that was designing the fix, and it failed for the reason that round had just spent two passes identifying. A document that is correct, published, and unreachable is worth exactly as much as one that was never written.
Believed to advance: nothing in this log is a stronger reason for the tool to
exist. reachable is why the document can be fetched. pull and check are
why it is still true when you read it.
Belief that could be shown wrong: that the tool would have helped. I would have had to already depend on that document to have pulled it — and the thing I needed was the one I did not know I was missing. A fetcher does not solve discovery, and this story is partly a discovery problem wearing a freshness problem's clothes.
2026-09-07 — supersedes the reachable entry: publication is not publicness marmalade
The entry above records three publication failures found by reachable and says
all three are fixed. One of them was not a failure.
jeffry/homelab-cluster is private on purpose — it describes a running
cluster, and its owner does not want outsiders reading what is on it. It
publishes six documents to an audience that is not the public, which
publication.md permits in the word we misread:
What a repository has handed over for others to depend on.
Others, not everyone.
So the tool as logged would have reported a correct repository as broken. An
anonymous fetch returning 404 is the intended outcome there, and a check that
treats anonymous reachability as success produces a false positive on every
deliberately-private publisher.
What survives: the defect that round found in bedrock/starting.md was real,
and it was never about homelab-cluster. A public document promised that a
private repository was the better answer, and the promise was the defect.
The link was removed; naming it as private, the way .loom names settled,
would also have answered it and would have kept the better document findable.
That call is loom's and it is already made.
Believed to advance: a check that cannot fail teaches nothing, and one that fails on correct repositories is worse — it trains people to ignore it.
Belief that could be shown wrong: that anonymous is still the right default. If most publication here is to private audiences, the default is wrong and the command should refuse to run without being told who to ask as.
2026-09-07 — measured: a read token, and what a host will tell you marmalade
A gitea read token was issued for one session and destroyed after. Recorded because these are facts about the host that nothing else here writes down.
A read token authenticates raw HTTP on a private repository. 404
anonymously, 200 with Authorization: token or Bearer, and If-None-Match
returns 304 authenticated. So add and check work against private
repositories — the assumption the specimen made and nobody had run.
/api/v1/repos/{o}/{r} reports admin, push, pull: true for a token that
cannot write. Those are the account's rights.
But a refused write names the token's scopes in the error body. So:
A token cannot be asked what it may do. It can only be told, by being refused.
Supersedes the specimen's "the name is the only record", which was too strong.
For this tool the rule is unchanged and the reason is different: loomctl
never writes, so it never provokes the only response that would tell it anything.
Belief that could be shown wrong: that gitea keeps doing this. The scope list in an error body is not a documented interface — it is a message, and messages change.
2026-09-07 — the CLI has nouns, and one of them is not built marmalade
Decided: loomctl, with subcommands. loomctl external … now; loomctl beads … when it exists.
Believed to advance: external is what somebody else published that we depend
on. The verb check means the same thing wherever it appears — ask somebody
else whether what we believe is still true.
Declined: a plugin boundary. kubectl and gh grew plugins after somebody
outside wanted in. The seam gets built when it is a reported gap, not a designed
one — which is how loom says that arrives, with a name attached and a workaround
recorded beside it.
Declined: a field where a repository declares who it published for. It would
let a tool compare intent to result, and it asserts a category nobody decided.
The better version needs no declaration: derive it from the other side, by
reading everybody's externals/ to see who actually depends on you. That is
registry's shape.
Belief that could be shown wrong: that one noun justifies the structure.
Until beads exists, loomctl external is a subcommand with nothing to be
distinguished from.
2026-09-07 — declined: published check marmalade
Considered and rejected. The command that would ask whether somebody who is not you can fetch what you published.
Because it simulates a consumer, and registry will have real ones. An
observed fetch beats a synthetic probe — the probe says a stranger could have
fetched it; a registry says somebody did. Building the probe first builds a
worse version of the thing that is coming.
And it removes the only thing complicating the credential story: --as <context> existed almost entirely to serve this command.
Belief that could be shown wrong, two ways:
registrynever materialises, and nothing ever asks the question.registryrecords declared consumers rather than observed fetches. "Who says they depend on us" is not "who can actually read us" — then this comes back.
Cost of deciding: the command found three real problems in one afternoon, one of
which was a false positive it would have produced forever. The workaround is one
curl, and a real need with a cheap workaround is a gap rather than a missing
feature.
2026-09-07 — kept, though nothing implements it marmalade
A check performed with your own credential proves nothing. It helps you complete alone an act that, by definition, you cannot — and then tells you it was fine.
Believed to advance: whoever builds registry's consumer view needs this
before they design it, because the tempting implementation is for the publisher
to verify their own publication — the same mistake with a database behind it.
And it is cheap to detect: /api/v1/user gives the acting login, the
repository gives its owner. One request, and nobody thinks to make it.
2026-09-07 — external list talks to git, measured marmalade
Decided: list enumerates a publisher's .loom/published/ by shelling out to
git — clone --filter=blob:none --depth=1 --no-checkout, then ls-tree.
Believed to advance, and measured across three hosts:
git archive --remote gitea ok · github 422 · gitlab 404
partial clone gitea ok · github ok · gitlab ok 124–144KB
REST /api/v1/repos/{o}/{r}/contents/{p}
/repos/{o}/{r}/contents/{p} (api.github.com)
/api/v4/projects/{url-encoded}/repository/tree
The argument is consistency, not thrift. One mechanism against three hosts
instead of three adapters — and REST 404s on a private repository, which is
the case that matters.
The failure mode to build for: --filter is a server capability and git's
fallback is silent, warning filtering not recognized by server and
downloading everything. --depth=1 bounds it. Detect the warning and say so.
Belief that could be shown wrong: that shelling out stays simple. The first
time we need output git does not print stably, the library trade looks
different.
2026-09-07 — nothing needs ssh, and nothing needs write access marmalade
Decided: every transport is HTTPS on 443, and no token this tool is given
should carry write:repository.
Measured: partial clone with the blob filter works over gitea's smart
HTTP, so list never needed ssh — it was built that way out of habit.
The tool writes only to the working tree — a fetched document, a lock, a facet — and never commits or pushes. Committing and pushing are the person's, under their own credentials.
Same reason
cartgives for an agent committing and not pushing: the consequences of a push land on people it cannot experience. A tool that can push is a tool that can publish, and publishing is an act somebody performs.
Implementation: never build https://<token>@host/… — git writes it into
.git/config and it surfaces in git remote -v and logs. Pass
-c http.extraHeader="Authorization: token …" per invocation instead.
Belief that could be shown wrong: that every host we meet allows token auth
over git-HTTPS. A host that does not would need ssh for list and nothing
else.
2026-09-07 — untested, and load-bearing marmalade
Nobody has confirmed that a gitea PAT scoped read:repository authenticates
git over HTTPS, not only the API and raw files. Those are different paths in
gitea; the token we had was measured only against the second.
Measured: an unauthenticated HTTPS clone of a private repository returns
Failed to authenticate user — so something is required, and which something is
unknown.
Recorded rather than assumed because it is the first thing to run against the next token, and because if a read scope is not enough, that is the only argument for keeping ssh.
2026-09-07 — decided by fallback: where the credential lives marmalade
Nobody chose this; the fallback became the decision and that is recorded so it can be found.
One file in ~, kubeconfig-shaped, holding hosts and credentials together.
Never in a repository, and nothing that reads from a repository.
Believed to advance: the config is no longer only a secret — it is how you talk to a host at all, API shape, ssh port if it ever matters, which transport a command uses. Some of that is not secret and several people would want the same values.
The split is expected and was not done: a host list that can be committed, and a credential file that cannot. It was named rather than decided.
Belief that could be shown wrong: that one file is fine until somebody shares. The first time two people want the same host list, the file has to split, and doing it later means moving a file people have already written by hand.
2026-09-07 — Go, and git on PATH marmalade
Decided: loomctl is written in Go.
Believed to advance: it gets installed on other people's machines — a laptop,
a cluster node, a CI runner — and a single static binary is the difference
between "download it" and "first install a runtime." The work is precise HTTP
header control, shelling out to git, and reading files; the standard library does
the first and third with no dependencies.
Considered: TypeScript on npm. A real distribution channel and the familiar one. Not chosen because it puts a runtime in front of a tool meant to be run casually and often — including by agents in loops that pay start-up cost every time.
Consequence: git must be on PATH. list shells out rather than using a
library, because the behaviour measured across three hosts is git's behaviour —
filter support, the silent fallback, the user's existing credential helpers.
list is the only command that needs it, which makes list the seam if this
ever bites.
Belief that could be shown wrong: that distribution is the deciding
constraint. If loomctl only ever runs in one or two places, the binary
advantage is worth little and the choice was made on a cost nobody pays.
2026-09-07 — a wrinkle in the isolation role marmalade
osprey cast the isolation role as: the cart on the default branch, the work on
a branch named for the cart. It assumed the work happens during the round.
Both rounds so far have been design rounds that decide work happening after. So the branch is named for the round that authorised it, and outlives the cart that named it.
Recorded rather than fixed. The role still works; its justification was written for a case that has not yet occurred. If a round ever does produce code while it is open, nothing here changes.
2026-09-07 — loomctl external exists, and its first run reconciled two documents marmalade
Built: list, add, check. Go, no dependencies outside the standard
library, git shelled out for list only.
The first real run did the thing the tool is for. Eight documents were
adopted by hand before it existed; check reported all eight unlocked, and
add locked them — and two came back changed, bedrock/starting.md and
cart/cart.md. Neither change would have been noticed by anybody.
Believed to advance: the lock is what makes check possible at all. With
no lock there is nothing to compare and the only honest report is unlocked —
and the tool refuses to invent one by adopting whatever the remote currently
serves.
Belief that could be shown wrong: that a per-person path derived from the URL
is good enough. <host>/<owner>/<repo>/<name>.md guesses that the first two
path segments name an owner and a repository, which is true of gitea, GitHub and
GitLab and is not a rule. --path exists for when it is wrong.
2026-09-07 — supersedes the isolation role: a cart is not committed marmalade
cart is now v1 and it changed the thing we cast a role on.
So the cart is not committed. It lives in the working tree of the machine the two presences share, and
.loom/cart/is ignored by version control.
The reason is not tidiness: a committed cart grows a third file by itself. The two-file rule defends against somebody asking for one; version control does not require anybody to ask — anyone who can clone can add a third, and the agreement's test is never invoked because nobody had the conversation.
And it is what makes a round end. Committed, a cart is gone from the tree and permanent in history — so "gone" means "no longer live" and the negotiation stays quotable forever. Ignored, gone means gone.
So .loom/cart/ is now in .gitignore. The isolation role entry above
assumed the cart lived on the default branch; the cart lives on no branch.
What survives of that entry is the other half: work is isolated on a branch named
for the round that authorised it.
Not undone: osprey and marmalade are already in this repository's history.
Rewriting history to honour a rule adopted afterwards would cost more than it
buys, and the two rounds are quotable forever, which is exactly what v1 says
not to want. Recorded rather than repaired.
Belief that could be shown wrong, and it is a real conflict: the annotation protocol this repository works under says commit before dissolving, git is the only archive of the conversation. An ignored cart has no archive, so dissolving a notes file destroys the annotations outright. One of the two is wrong and it is not ours to settle.
2026-09-07 — what the planted change actually proved marmalade
cart.md was changed upstream deliberately, without telling us, to see whether
the tool would notice. It did — and the sequence is worth recording, because
the obvious reading is wrong.
check did not catch it. All eight documents were unlocked, and unlocked
means I cannot tell you. It was add that revealed the change, by
overwriting the file — and the only reason anybody saw what had changed is that
git happened to be watching the working tree.
So the mechanism is proven and the workflow is not. A conditional request against a lock works. A document nobody has locked is a document nobody is checking, and it stays that way silently.
Decided, as a consequence: add now reports when it replaces local content
that differs from what the publisher is serving. It used to say only
adopted. A copy that differs is the only evidence that something moved while
the document was unlocked, and destroying it silently is how a change nobody saw
becomes a change nobody can find.
Belief that could be shown wrong: that a note is enough. add still
overwrites — it does not stash the old bytes anywhere — and outside a git
working tree the note tells you something was lost without letting you see it.
If that bites, add needs --dry-run or a refusal.
2026-09-07 — the unowned half: somebody has to run it marmalade
Nothing here answers when check runs.
bedrock says it about running systems and it is just as true of this:
nothing serves the truth, so the only mechanism is somebody looking. The tool
makes looking cheap; it does not make it happen.
Recorded as a need with no owner rather than a feature, because the answers are all outside the tool — a git hook, a CI job, an agent's session start, a scheduled run — and choosing one here would put a scheduler inside a fetcher and a comparator.
Belief that could be shown wrong: that staying out of it is right. If in
practice nobody ever runs check unaided, a tool that only reports when asked is
a tool that reports nothing, and the thing we declined to build is the thing
that was needed.
2026-09-07 — a changed external is a polad, which the specimen said first marmalade
Decided: check stages what moved into .loom/cart/current/polad/, a
candidate artifact shaped exactly like what it would become, whose exits are
apply or discard.
This was in the specimen and we lost it. "A changed external becomes a polad in the cart, and somebody decides." The round that discarded the specimen discarded this with it, and it came back only because somebody asked what the stash should be.
Believed to advance: externals says reconciliation runs the other way —
given what changed in theirs, what do we change in ours — and the facets
usually survive while the manifests, the config and the code that a usage named
are what move. So staging prints the .usages.md beside it, which is the
file that names what to go fix, and says so when there is none: a usage that
does not name what it justifies is half a usage, and no usage at all is a document
nothing records a dependency on.
With no cart open, check reports what moved and stages nothing. The tool
does not open a round — a cart is a bounded exchange between two presences, and
starting one is somebody's act, not a side effect of asking about freshness.
Belief that could be shown wrong: that requiring an open cart is right. If most changes arrive when no round is open, the useful behaviour is the one that never runs, and the polad needs somewhere else to live.
2026-09-07 — supersedes "add says what it replaced": add does not replace marmalade
add adopts what is not here, and refuses what is already adopted. The
earlier entry made add announce an overwrite; it no longer overwrites at
all.
Believed to advance: a command that both adopts and re-fetches is a command
that overwrites the only evidence a change happened. Splitting them gives each
one job — add adopts, check notices.
One exception, and it is the only way out of a dead end: a document that is
present but unlocked was fetched by hand before the tool existed. Nothing
records its origin and the path does not round-trip, so check cannot ask about
it and add refusing would strand it forever. So add accepts it, and the
bytes decide:
- identical → the lock is written and nothing is rewritten. The assertion
a lock makes — this local copy is the one being served — is then verified rather
than assumed, which was the whole objection to adopting a remote
ETagblindly. - different → staged as a polad; the local copy is left alone, because a copy that differs is the only evidence that something moved while nothing was watching.
Measured on this repository: eight documents adopted by hand, all eight locked with nothing rewritten.
2026-09-07 — apply, because the lock is the half a person forgets marmalade
Decided: loomctl external apply [path…] moves a staged polad into place and
moves its lock with it.
Believed to advance: applying by hand is one mv, and it leaves a lock
describing the copy you just replaced — which is exactly the drift the lock
exists to prevent. The polad carries the ETag that was served with the bytes
somebody reviewed, so applying locks what was actually read rather than whatever
the publisher serves at apply time.
This is not the tool fixing anything. It executes a decision a person already made, one document at a time, after the report.
Belief that could be shown wrong: that two exits are enough. For an external, discard does not mean the change goes away — the upstream moved whatever we do — so discarding is really "knowingly stale", and nothing currently records that choice. If people discard often, that silence is the next defect.
2026-09-07 — declined: a separate adopt verb marmalade
Considered: loomctl external adopt <path> <url> — a command whose job is
to say "this copy came from there", for a document that is present but
unlocked. Both parties reached the deadlock independently and this was the
other way out.
Not done, because the byte comparison makes the verb unnecessary. Whatever
the command is called, it must not trust the claim — it has to fetch and
compare, because the whole point is that nobody knows whether the local copy is
still a copy. Once it does that, it is add with an origin supplied, and the
user's intent in both cases is the same sentence: I depend on this document and
here is where it lives.
And a verb earns its place by naming an act, not a state. "Present but unlocked" is a condition a repository is temporarily in — a permanent command for it advertises a transitional situation as a normal one.
Belief that could be shown wrong: that the condition is transitional. If
people and agents keep dropping documents into externals/ by hand — and they
will — it is a recurring category and not a migration, and then it deserves its
own word, discoverable in --help rather than found in a hint.
The domain word survives regardless: externals says adopting is fetching a URL,
and the tool says adopted throughout.
2026-09-07 — the credential is not needed here, and is not gone quince
jeffry/homelab-cluster is public and jeffry/homelab-impl is private.
Adoption flows public → private, so nothing this repository depends on requires a
credential, and loomctl has been exercised end to end without one.
That does not remove the PAT. It moves when you need it. externals now
says: do not adopt from a source less readable than the repository you are
adopting into. Which sorts the cases:
- a public repository adopting — may only adopt public documents, so it never needs a credential, and needing one is the signal that something is wrong.
- a private repository adopting private documents — legitimate, and needs one.
So needing a credential stopped being a capability and became a signal. The tool cannot tell the two apart — it sees that the source is private and cannot see who may read the repository the copy lands in — which is exactly why
addwarns rather than deciding.
Belief that could be shown wrong: that public-adopting-public covers the common case. The moment a private repository wants to depend on another private one, the credential is required and untested.
2026-09-07 — status change: the git-over-HTTPS assumption is unexercised, not blocking quince
An earlier entry records that nobody has confirmed a read-scoped PAT authenticates git over HTTPS, and calls it load-bearing. It is no longer blocking.
It bears on external list against a private repository, which is the
private-to-private case above and does not occur here. The path is unexercised
rather than untested-and-in-the-way.
Recorded because the code did not change and its status did, which is the kind of thing only a log says. It stays the first thing to run against the next token.
2026-09-07 — the completeness case has a mechanism: it is the facet quince
The open question was: a 200 tells you a document moved and says nothing about
whether your casting still covers it.
check prints the document's .usages.md when it stages a polad, and for an
agreement that is the file its roles are cast in — so the question is answered
by reading the facet the tool just pointed at.
It failed on the first real change because externals.md had no facet at all,
and the tool said no .usages.md — nothing records what depends on this: a
correct report and useless as a prompt.
So both were written. externals.usages.md names which Go file implements
which rule — an unusual usage, because this repository implements the convention
rather than using it — and cart.usages.md gained the v1 casting it was missing.
Belief that could be shown wrong: that pointing is enough. Nothing checks that a facet was updated, or that it was even read.
2026-09-07 — decided: nothing committed announces an open cart quince
A cart is local and untracked, so a clone cannot see that a round is open.
That is the boundary and not a defect: v1 makes the cart local to the
working tree the two presences share, and somebody who has only cloned is by
construction not one of them.
.gitignore records that carts happen here. Nothing records that one is open,
and the asymmetry is deliberate — a mechanism for announcing something
designed to be ephemeral is the first step in it not being ephemeral.
Belief that could be shown wrong: that the shared working tree is the right unit. If two presences ever work from different machines, the cart has nowhere to live, and everything above stops being true at once.
2026-09-07 — declined for now: reference-only adoption quince
externals offers two ways out of the confidentiality rule. We implement
neither, and this records why the first is not built.
Reference-only is mechanically small: a lock with no file. check never
touches the copy — it sends If-None-Match and reads the status — so the only
code that changes is telling deliberate absence from loss, which is one optional
field in the lock. The facets stay, which is the ownership line drawn
exactly: the facet is ours, the document is theirs.
The cost is larger than the document suggests, and it is why this is worth an
entry rather than a TODO: with a copy, CHANGED gives you a diff, and today
the diff was the whole answer. Reference-only keeps no old bytes, so it tells
you that a document moved and never what moved — and the question
externals says reconciliation asks is a question about the delta.
So it is not adoption minus offline reading. It is adoption minus reconciliation-by-diff.
Not built because we have no instance. Everything this repository adopts is
public. And the convention's other exit — ask them to publish — is the one that
actually occurred: homelab-cluster went public and the problem dissolved.
The better exit made the worse one unnecessary in the only case we have had.
Belief that could be shown wrong: that the case stays hypothetical. A
private repository here depending on another private one makes it real —
homelab-impl is the obvious candidate — and then the design above is a few
hours.
The warning now names both exits, including the one we have not built. Telling somebody a rule and not the way out of it is how a guardrail becomes something people route around.
2026-09-07 — measured: only gitea resolves its own short raw URL rowan
A lock must record a resolved URL — a short form follows whatever the default branch is at the time you ask, so a branch rename reports as a change in the document. Adopting by name means building that URL, and the three hosts do not behave alike:
gitea /{owner}/{repo}/raw/{path} 303 -> /raw/branch/main/{path} resolved
github raw.githubusercontent.com/{o}/{r}/HEAD/{path} 200, no redirect NOT resolved
gitlab /{owner}/{repo}/-/raw/HEAD/{path} 200, no redirect NOT resolved
HEAD works on all three and resolves on none. It would put a moving ref in
the lock, which is the hazard we recorded and then nearly built.
So gitea's redirect does the work, and the other two need the branch resolved
first — git ls-remote --symref <url> HEAD, which is one round trip and no
clone.
Belief that could be shown wrong: that gitea keeps redirecting. The short form is a convenience, not a documented interface, and if it stops we resolve the branch the same way as everybody else.
2026-09-07 — supersedes "git is needed for list only" rowan
Adopting by name against github or gitlab needs git ls-remote to resolve the
default branch. Against gitea it does not, because the redirect answers the
same question.
So the earlier claim — list is the only command that needs git, which makes
list the seam — is now wrong for two of three flavors. The seam is unchanged
in practice here, where everything is gitea, and the entry would otherwise read as
still true.
Recorded because the code changed in a way that quietly moved a boundary somebody might rely on.
2026-09-08 — measured: the private path works, and nothing is unexercised now rowan
Supersedes the entry that recorded the git-over-HTTPS assumption as unexercised. It has been run. A read-scoped token, issued for one session and destroyed after.
git clone --filter=blob:none --depth=1 over HTTPS + token, private repo OK, 124KB
loomctl external list jeffry/homelab-impl OK, authenticated
short raw URL + 303, authenticated 404 anon -> 200, resolved branch
If-None-Match across the redirect, authenticated 304
So a private repository can adopt from another private one, by name and by
URL, with check working off the lock afterwards. Every path in the tool has
now been run at least once.
Incidentally confirmed: homelab-impl publishes nothing and adopts six documents
from homelab-cluster, each with a .usages.md. Consuming inward, which is
the arrangement that made the confidentiality hazard impossible in that shape.
2026-09-08 — the tool is a mast, not a lock rowan
Recorded because it decides a question that keeps coming back, and nothing in the code says it.
loomctlgrants no access. It reads what your credentials already let you read. Anything it does, a person could do with copy and paste — and the locks mean nothing outside the tool and the discipline of the agreement.
So it warns and does not refuse. The point is not to make the wrong thing impossible; it is to make it deliberate — the same move as annotating a file you could simply edit.
Which is also why add <full-url> stays, even though it can reach outside a
publisher's .loom/published/ and is, in that form, a small general-purpose file
fetcher. A general-purpose tool is not the aim, so the escape hatch is kept
and made to say what it is, rather than removed.
What the tool adds over copy and paste is not restriction. It is the record —
origin, resolved branch, ETag. A pasted document has no provenance, so
nobody can later ask where it came from or whether we were allowed to have it —
not because the question is hard, but because the evidence is gone.
2026-09-08 — the confidentiality check is coarse, deliberately rowan
add now asks whether this repository is readable anonymously, by resolving
origin and making one unauthenticated request. So the warning fires when the
source is private and the destination is public, rather than on every adoption a
private repository performs — which was the previous behaviour and would have
been noise in exactly the workflow that is legitimate.
Belief that could be shown wrong, and it is a known blind spot rather than a guess: the signal distinguishes public from not-public and nothing finer. Two repositories private to different people is the case where private-to-private genuinely widens access, and this check cannot see it — so the tool says so, in the message, instead of implying a verdict it has not earned.
Failing open is deliberate: no origin, or an unparseable one, reports "I cannot tell" and warns. "Cannot tell" must never read as "not public."
2026-09-08 — three gaps closed, and the workaround they justified is now the rule rowan
publication, recording-decisions and cart were amended in answer to gaps
filed from here. Closed at reconciliation rather than at repair: each stayed
true of the copy we held until check reported 200. The gap files for the
first two are deleted; cart.gaps.md keeps four of five.
What survives is what they justified, and in every case it is the same shape — something we invented locally became specified behaviour, unchanged in practice and entirely changed in standing:
wal.md. We built it beforecart/v2named it, and only because it was suggested to us.v2cites that: a practice that works only when somebody is standing there to suggest it is not a practice.- "A fetch you did not perform." Ours was a declined command and an insight
kept without an implementation; it is now the test in
publication. - The compaction rule, including the four kinds that may never be dropped, plus one addition we did not think of: a compaction is itself a decision and gets an entry.
2026-09-08 — declined: fixing annotating rowan
Four real gaps stand unfixed, deliberately. It is the most-depended-on document here and a revision costs every adopter an afternoon.
Two of the four are not gaps in the agreement at all. The rule exists — the source hash that detects a violated freeze, and dissolve as the definition of what responding is — but it lives in a local editor protocol and not in the agreement. That needs a decision about where a rule lives, which is not an amendment and is not ours.
Local answers keep running: verify the source hash and the anchors before responding, and say so if they disagree.
2026-09-08 — a casting file goes stale on amendments that add no role rowan
Twice in one day. cart/v1 and cart/v2 each invalidated nothing we had
cast and each left cart.usages.md incomplete.
checkreports that a document moved and cannot say whether your casting still covers it. The mechanism is that it prints the.usages.mdbeside a staged polad, which it did, both times. It points; it cannot make anybody read.
Belief that could be shown wrong: that pointing is enough. Nothing checks that a facet was updated, or that it was opened.
Related, and the reason this entry can be trusted: this round arrived headed
rowan in a file named for a round that had already converted, with no name
file. The name is what tags durable entries, so a mismatch mis-files the
record permanently. The heading won; it is recorded in cart.usages.md.
2026-09-08 — declined: a round on the third-file question rowan
Not opened, and not sent upstream as a gap either. Left open in
cart.gaps.md where it was filed.
Because it may not be a category. Ours appeared because a round was converted while somebody was still writing, which the agreement already forbids. A third file that only ever arrives as a conversion-timing failure has its fix upstream, and the choice between moving it and rejecting it never has to be made.
Before writing the rule, count how many arrive any other way.
One instance is not a sample — which is the reason cart/v2 gave for leaving
it alone, applied one step further.
2026-09-08 — orient, and declined: a second orientation file sorrel
Decided: one generated file, .loom/orientation.md, for whoever arrives
next. It restates no rule — every rule it might repeat is owned by a
document already in the working tree, and a copy of a rule is a copy that goes
stale.
Declined: splitting it into an externals half and a publishing half. The
splitting rule is generate what varies, adopt what does not — and the
publishing half varies not at all and is already adopted, so publication.md
appears in the index like any other adopted document, in exactly the repositories
that adopted it. A second command would emit a file whose whole content is a
pointer to a file already in the tree, and would revive the published noun
that went with published check.
Declined: .loom/externals/README.md as the path, which was measured rather
than argued:
$ loomctl external check
README.md unlocked no origin recorded — ...
check walks that directory for adopted documents, so a generated file there
reports as unlocked in every repository using the feature, forever. And the
deeper reason is the invariant: everything under .loom/externals/ is somebody
else's document, which is what makes "do not edit these" a rule you can state in
four words.
The .usages.md is pointed at, not summarised. What depends on a document is
free prose, and anything that extracts a claim from prose will eventually extract
it wrong.
Belief that could be shown wrong: that a fixed path is worth more than a
configured one. It only pays if it is the same everywhere, which is not ours
to decide and is with bedrock.
2026-09-08 — a probe is not a fetch, and confusing them clears alarms sorrel
Recorded because the bug was one line and the trap is permanent.
Visibility was computed by one function used in two places. At add time,
"no credential is configured" means the fetch that just succeeded was anonymous,
so the source is public — sound. In the audit, nothing is being fetched:
"no credential configured" says nothing about whether a stranger could read the
URL, and returning public there would have silently cleared real
alarms.
The worst failure available to a check is to report that it found nothing.
Probing is now its own function that always asks with no credential at all, because what matters is what a stranger can read and not what we can. Found by testing the alarm path rather than after shipping it.
2026-09-08 — orient, not prompt, and why that was mine to notice sorrel
Renamed before it shipped. prompt reads as "produce something to feed a
model", and the file is a table of contents a person can read.
The correction was right and the assumption behind it was mine: prompt was
not being used in the narrow sense, and I took it at the narrowest reading
available. Second time in two days — the first was reading others as
everyone in publication.md, which cost a command that was built, validated
three times, and then declined.
Recorded as a working note rather than a decision: when a word could be read narrowly or broadly, this repository has evidence that I pick narrow.
2026-09-08 — published: what the tool guarantees tansy
.loom/published/guarantees.md, and step 4 of publication was run: 200
anonymously, off no credential of ours.
The earlier decision did not reverse; its condition fired. .loom/published/
was recorded as deferred rather than declined, on the condition that somebody
depends on the tool's behaviour rather than reading its help. A docs site whose
build calls add, check and apply is that consumer.
The delta is not the command list — that is a copy of what the code owns.
--help says what the tool does; a published page says what will keep being
true, and a reader cannot learn from a binary which of its behaviours survives
the next version.
The page includes what is not promised, which is the half a reimplementation needs most: the command surface, the orientation file's format across versions, the precision of the visibility signal, and that anything is checked when nobody is looking.
2026-09-08 — we do not promise the surface; we promise the change lands tansy
A consumer's README tells a person to type loomctl external add loom/<repo> <doc>.md. Nothing parses it — which is exactly why no check would catch a
rename. The thing that moved would not be a document anybody adopted.
Promising the surface would freeze the CLI. So the promise is narrower and runs:
We do not promise the surface holds. We promise a change to it lands on the page you already lock.
guarantees.md carries an append-only Surface changes section, so a rename
reports 200 to every consumer the day it happens.
Belief that could be shown wrong: that anybody remembers to write in it. Nothing enforces the section — the same weakness as a facet nobody updates.
2026-09-08 — two ETags can be compared and never ordered tansy
Found by accident, and it is a consequence of a rule we published this morning.
The docs site stamped a hand-written guide with the ETag it was read against,
and its banner said written against an older guarantees. It was backwards:
the guide was ahead of the site's copy, not behind.
And it cannot be fixed by trying harder. An ETag is opaque — that is the
point of it, and we guarantee we never interpret one. So a consumer can know
that two versions disagree and can never know which is behind.
The banner now claims no direction. Anybody building freshness on this inherits the same limit, and it belongs in the log because it follows from the guarantee rather than from anybody's code.
Consequence nobody predicted: reconciling cleared the banner with no acknowledgement, which is correct — an acknowledgement is for a guide that is behind, and this one was ahead.
2026-09-08 — compacted, and it barely compressed tansy
A compaction is itself a decision, so this is the entry recording-decisions
asks for. Six entries dropped, fifty kept. The uncompacted log is in git
history, which is where a reader who needs it goes.
Dropped, each because a competent reader can now recover it from the artifact, and here is which artifact:
what this tool is—.loom/published/guarantees.mdandREADME.mdthe lock, and what it holds—guarantees.mddocuments the formatunlocked is a state—guarantees.md, andaddrefuses in code--help is the documentation surface—README.mdsays so and points therethe lock records visibility, and the word it will not use—guarantees.mdcarries the field and the refusal of the wordprivateconversion now destroys, so the log is the only record—cart/v2, adopted here, andcart.usages.mdnameswal.mdas the mechanism
Every one of them became droppable by being published. Nothing was summarised away; the content moved into a document somebody else can lock, which is a stronger place for it than a log only we read.
What did not compress, and why that is the finding
Fifty entries survived, and the reason is the rule rather than sentiment. Almost all of them are one of the four kinds that may never be dropped:
Declines — init, published check, a separate adopt verb, a plugin
boundary, an audience declaration, reference-only adoption, resolving 404 over
ssh, a second orientation file, worktrees, a spec, fixing annotating. Not one
is recoverable from the code, because the code is what they are absent from.
Measurements — the ETag is a blob hash on gitea and not on GitHub; three
hosts and one partial clone; only gitea resolves its own short raw URL; a refused
write names the token's scopes; a private repository over HTTPS with a read
token. Facts about other people's systems, which no artifact here holds.
Supersessions — publication is not publicness; a cart is not committed; add
does not replace; git is not needed for list only; the private path is
exercised. Each is a change of mind, which is most of what a reader wants.
Beliefs shown wrong — the specimen's hash-as-lock; others read as
everyone; a probe treated as a fetch; a banner that claimed a direction two
opaque strings cannot support.
The log is long because the work was exploratory, not because it is padded. A project that mostly built things would compact to a page. This one mostly found out it was wrong about things, and that is the part the code cannot hold.
Belief that could be shown wrong: that fifty is readable. It is today.
The next remedy is not another compaction — it is the guide bedrock needed,
and it is not needed yet.
2026-09-08 — a 404 at adoption has a third reading, and it is resolvable
externals names two readings of a 404 — withdrawn, or access lost — and
they are the two a locked document can have. Adoption by name has a third:
a document that was never there under that name.
Found in use: loomctl external add jeffry/homelab-cluster database reported the
ambiguity for what was a missing s.
The tool was reporting an ambiguity it had the means to resolve. For the
by-name form it knows the repository, so on a 404 it now lists the published
surface:
loomctl: jeffry/homelab-cluster publishes no "database.md". It publishes:
databases.md gitea.md private-access.md ...
And it only claims that when the listing succeeds. If listing fails too, the
repository itself is unreachable and the original ambiguity is the honest
answer — the same discipline as reporting public and not-public rather than
private: say the thing you verified.
Not a gap against externals. The third reading cannot occur where that
document is speaking, which is check against a lock. It appears only at
adoption, which is ours.
Belief that could be shown wrong: that an extra round trip on a failure is free. It is one partial clone on a path nobody takes twice, and if adoption against large repositories becomes common the listing should be bounded.