Compare commits

...
9 Commits
Author SHA1 Message Date
jeffryandClaude Opus 5 d7b169b87e a 404 at adoption has a third reading, and the tool can resolve it
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
2026-09-08 12:02:44 -04:00
jeffryandClaude Opus 5 a5093c1efc compact the event log: six dropped, fifty kept, and the result is the finding
recording-decisions now permits compaction and names four kinds that may never be
dropped. Applying it removed six entries and left fifty, and the ratio is worth
more than the saving.

Every entry dropped became droppable by being published. what this tool is, the
lock format, unlocked is a state, --help as the documentation surface, and the
visibility field all now live in .loom/published/guarantees.md or README.md, and
conversion-destroys lives in cart/v2 and cart.usages.md. Nothing was summarised
away — the content moved into documents somebody else can lock, which is a stronger
place for it than a log only we read. The entry names each one and where it went,
so the compaction is auditable rather than trusted.

What did not compress is the point. Almost everything left is a decline, a
measurement, a supersession, or a belief shown wrong. Declines are not recoverable
from code because the code is what they are absent from; measurements are facts
about other people's systems that no artifact here holds; supersessions are changes
of mind, which is most of what a reader wants. The log is long because the work was
exploratory, not because it was padded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 10:49:41 -04:00
jeffryandClaude Opus 5 045924ec36 tansy converts: three entries, no shelf
Publishes what the tool guarantees, on a condition that fired rather than a
decision reversed. Records the narrower promise that replaced promising the command
surface — a change to it lands on the page consumers already lock, so a rename
reports 200 the day it happens.

And records a limit found by accident that follows from a guarantee rather than
from anybody's code: an ETag is opaque, we promise never to interpret one, so two
of them can be compared and never ordered. A consumer can know two versions
disagree and can never know which is behind. Anybody building freshness on this
inherits it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 10:48:47 -04:00
jeffryandClaude Opus 5 d3fec24bc6 guarantees: promise visibility about the surface we do not promise
loom's .usages.md for this page found the hole. The docs site depends on the
command surface — its README and publish script tell a person to type loomctl
external add loom/<repo> <doc>.md — and this page explicitly does not promise it.
Nothing parses the command, which is exactly why no check would catch a rename:
the thing that moved is not a document anybody adopted.

The answer is not to promise the surface, which would freeze the CLI. It is to
promise that a change to it lands on this page — which consumers already lock — so
a rename reports 200 the day it happens. The exclusion goes from "you are on your
own" to "you will be told", and it costs one append-only section.

Same shape as a supersession in the event log: the record is not that the thing
never changes, it is that the change is findable by whoever depended on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 10:41:29 -04:00
jeffryandClaude Opus 5 4e7aa1ff1f loom-cli publishes one page: what the tool guarantees, not what it does
The earlier decision stands rather than reverses. .loom/published/ was recorded as
deferred, not declined — it earns publication when somebody depends on the tool's
behaviour rather than reading its help — and a docs site whose build calls add,
check and apply is that consumer. The condition fired.

The delta is real and is not the command list: --help says what the tool does, and
a reader cannot learn from it which behaviours survive the next version. The
sharpest case is loom's — the tool is now a second holder of externals' rule that
the lock is the publisher's ETag and never one you compute, and nothing anywhere
said the tool holds it, in a repository where a specimen was written that computed
a hash instead.

Includes what a contract must include and the ask did not mention: what is not
promised. 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. That is the half a reimplementation needs most, and it keeps the page from
quietly committing us to the CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 10:33:14 -04:00
jeffryandClaude Opus 5 af8f739bfe sorrel converts: four entries, no shelf
Extracted at conversion rather than staged as it went — there was no wal this
round, and the answers lived in the daily that the rm destroys, which is precisely
the failure v2 added the rule to prevent. Worth noticing that the round which
answered a question about durable records is the one that nearly lost its own.

Records two declines with their reasons: a second orientation file, since the
publishing half varies not at all and is already adopted so it appears in the index
by itself; and .loom/externals/README.md as the path, measured rather than argued,
because check walks that directory and would report a generated file as unlocked in
every repository using the feature, forever.

Records the visibility field with the word it refuses — public or not-public and
never private, because an anonymous request tells those apart and nothing finer,
and private would claim precision the signal does not have.

And records a one-line bug whose trap is permanent: visibility was computed by one
function used both after a successful fetch and as a probe, where "no credential
configured" means opposite things. In the audit it would have silently cleared real
alarms, which is the worst failure available to a check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 09:25:56 -04:00
jeffryandClaude Opus 5 b3617ed195 the lock records what the source could be read as, and check audits it
Access is verified once, at fetch, and the copy is durable — so whether an
adoption is still legitimate rests on the relative visibility of two
repositories, which somebody can change with a checkbox a year later without ever
seeing the adoption. The lock gains an optional fourth field and check turns that
from a silent permanent hazard into something that runs.

It costs nothing at add time, because the anonymous request already happened to
decide whether to warn and the answer was being thrown away, and one request per
run at check time rather than one per document, because only our own visibility
has to be current. The stored value decays in both directions, so a source
recorded not-public is re-probed only when the alarm would fire, and a source that
has since gone public updates the lock and says nothing.

Fixes a bug found while testing the alarm rather than after shipping it.
sourceVisibility returned public whenever no credential was configured, which is
sound at add time — the fetch had just succeeded anonymously — and wrong in the
audit, where it is a probe and not a fetch: it would have silently cleared real
alarms. Probing is now its own function that always asks with no credential,
because what matters is what a stranger can read and not what we can.

The value is recorded as public or not-public and never private: an anonymous
request tells those apart and nothing finer, so it cannot see two repositories
private to different people, which is the case that genuinely widens access.

Three-field locks still load, and unknown visibility round-trips as absent rather
than as a value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 09:22:42 -04:00
jeffryandClaude Opus 5 da6e8b9b51 loomctl orient: one table of contents, restating no rule
Generates .loom/orientation.md — what this repository depends on, where each copy
came from, and which facets sit beside it — for whoever arrives next, of any make.

One file rather than two, decided by loom's own rule. Generate what varies, adopt
what does not: 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 a noun declined when
published check went.

Restates no rule. The three moves are phrased as operations — what to run, what to
write, where — and every rule stays in the document that owns it. The .usages.md is
pointed at rather than summarised, because what depends on a document is free prose
and anything extracting a claim from prose eventually extracts it wrong. It says
nothing recorded when a document has no usages file, which is a finding rather than
an omission and is currently true of six of nine.

Byte-deterministic, pinned by a test that inserts records out of order and
generates three times. Written to .loom/orientation.md rather than inside
.loom/externals/, where check would report it as an unlocked external in every
repository using the feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 09:11:04 -04:00
jeffryandClaude Opus 5 1cc528c8aa sorrel: reconcile bedrock 82 and 83, and answer both prompts
publication.md and starting.md both came back 200 and are applied. Step 4 —
fetch it, from somewhere that is not your machine and not your account — is now the
procedure our declined command was an argument for.

Answers the Select for a fixed path but against .loom/externals/README.md, with a
measured reason: check walks that directory for adopted documents, so a generated
file there reports as unlocked in every repository using the feature, forever. The
deeper reason is the invariant — everything under externals/ is somebody else's
document, which is what makes do-not-edit a rule you can state in four words.
Proposes .loom/orientation.md, beside event-log.md, since .loom/README.md is
already given to what is in published/ and why.

Answers the Question yes. The visibility check was built as a moment and loom is
right that it is a fact that decays: access is verified once and the copy is
durable. It costs nothing at add time, since the anonymous request already happens
and the answer is thrown away, and one request per run at check time rather than per
document, since only our own visibility must be current. Adds the refinement that
the stored value decays both ways, so a source recorded private is re-checked only
when the alarm would fire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 09:05:38 -04:00
16 changed files with 895 additions and 115 deletions
Vendored
BIN
View File
Binary file not shown.
+212 -74
View File
@@ -7,21 +7,6 @@ by saying so.*
--- ---
## 2026-09-07 — what this tool is `osprey`
**Decided:** *`loom-cli` is a fetcher and a comparator.* **It fetches a document
you depend on and tells you whether the publisher's copy has moved.** *It reports
and never repairs.*
**Believed to advance:** *every act in the loom model is a file in somebody's
repository — publishing is writing one, adopting is fetching a URL, homing a gap
is an `mv`.* **Almost nothing needs a program**, so the program should be the
part that cannot be done by hand: an HTTP request, repeated.
**Belief that could be shown wrong:** *that reporting is enough.* **If every
report is followed by the same manual act, we have moved the work rather than
removed it**, and the missing command will be obvious.
## 2026-09-07 — the design lives here and not in a spec `osprey` ## 2026-09-07 — the design lives here and not in a spec `osprey`
**Decided:** *this log is the artifact of `osprey`.* **No specification document **Decided:** *this log is the artifact of `osprey`.* **No specification document
@@ -72,40 +57,6 @@ somebody's published surface or from a file they may rename at will.
location.* **If anybody writes code that parses it back into a URL, the path location.* **If anybody writes code that parses it back into a URL, the path
should stop looking like one.** should stop looking like one.**
## 2026-09-07 — the lock, and what it holds `osprey`
**Decided:** *one record per adopted document in `.loom/externals/.locks`: the
**resolved** origin URL, and the `ETag` it came with.*
**Believed to advance:** *the specimen said there would be no lock file, on the
strength of the `ETag`-as-blob-hash claim.* **That claim failed twice**, so the
file exists and is named for what it is. *It was briefly called `.etags`, from
when we believed there would be one field.*
**Resolved, and not the short form.** *Measured: gitea `303`s
`/loom/externals/raw/externals.md` to `/raw/branch/main/…`* — **so a lock holding a
short URL is locked to whatever the default branch is at the time you ask**, and a
branch rename reports as a change in the document.
**Belief that could be shown wrong:** *that one file is cheap.* **Two fetches in
one round conflict inside it, and the conflict is in a file no human can resolve
by reading.** *If that bites, it becomes one record per document and the tree is
mirrored twice.*
## 2026-09-07 — unlocked is a state `osprey`
**Decided:** *a document with no lock is reported as unlocked.* **`check` never
adopts whatever the remote is currently serving as the lock.**
**Believed to advance:** *adopting it would assert the local copy is the one being
served, which is the thing you were about to check.* **It is the hash assumption
in a different coat, and it fails silently in the one case that matters — a copy
somebody edited.**
**Belief that could be shown wrong:** *that anybody will run `pull` again to fix
it.* **If unlocked documents simply accumulate, the report is noise and something
has to lock them.**
## 2026-09-07 — `404` is unresolved, and that is no longer provisional `osprey` ## 2026-09-07 — `404` is unresolved, and that is no longer provisional `osprey`
**Decided:** *`check` reports a `404` as unresolvable — the document was withdrawn **Decided:** *`check` reports a `404` as unresolvable — the document was withdrawn
@@ -450,16 +401,6 @@ unknown.**
next token*, **and because if a read scope is not enough, that is the only argument next token*, **and because if a read scope is not enough, that is the only argument
for keeping ssh.** for keeping ssh.**
## 2026-09-07 — `--help` is the documentation surface `marmalade`
**Decided:** *`--help` is what a person or an agent actually runs when handed an
unfamiliar CLI, so it is the surface that has to be good* — **say what a command
does and what it will not do.**
**A `.loom/published/` for `loomctl` is deferred, not declined.** *The distinction
matters because `init` was declined.* **It earns publication when somebody depends
on this tool's behaviour rather than reading its help.**
## 2026-09-07 — decided by fallback: where the credential lives `marmalade` ## 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 **Nobody chose this; the fallback became the decision and that is recorded so it
@@ -789,21 +730,6 @@ designed to be ephemeral is the first step in it not being ephemeral.**
unit.* **If two presences ever work from different machines, the cart has nowhere unit.* **If two presences ever work from different machines, the cart has nowhere
to live**, *and everything above stops being true at once.* to live**, *and everything above stops being true at once.*
## 2026-09-07 — conversion now destroys, so the log is the only record `quince`
**Under `cart/v1` the cart is untracked, so converting a round deletes its dailies
outright.** *Every previous conversion left them in git.*
**So an entry that was not written before the `rm` is gone**, *and the write-ahead
log is the mechanism* — **stage as you go, because at conversion the cart is the
only copy and you are about to delete it.**
**And it sharpens a conflict recorded in `cart.usages.md`:** *the annotation
protocol here says commit before dissolving because git is the only archive.*
**With an ignored cart there is no archive, so dissolving a notes file destroys
the annotations outright.** *Both documents are loom's; this is where an adopter
can see the collision.*
## 2026-09-07 — declined for now: reference-only adoption `quince` ## 2026-09-07 — declined for now: reference-only adoption `quince`
**`externals` offers two ways out of the confidentiality rule.** *We implement **`externals` offers two ways out of the confidentiality rule.** *We implement
@@ -1003,3 +929,215 @@ file that only ever arrives as a conversion-timing failure has its fix upstream*
*One instance is not a sample* — **which is the reason `cart/v2` gave for leaving *One instance is not a sample* — **which is the reason `cart/v2` gave for leaving
it alone, applied one step further.** 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.md` and `README.md`**
- *`the lock, and what it holds`* — **`guarantees.md` documents the format**
- *`unlocked is a state`* — **`guarantees.md`, and `add` refuses in code**
- *`--help is the documentation surface`* — **`README.md` says so and points there**
- *`the lock records visibility, and the word it will not use`* — **`guarantees.md`
carries the field and the refusal of the word `private`**
- *`conversion now destroys, so the log is the only record`* — **`cart/v2`, adopted
here, and `cart.usages.md` names `wal.md` as 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.*
+13 -11
View File
@@ -1,12 +1,14 @@
# loomctl locks — one record per adopted document. # loomctl locks — one record per adopted document.
# path<TAB>url<TAB>etag The url is resolved: a short form would follow # path<TAB>url<TAB>etag[<TAB>visibility]
# whatever the default branch is at the time you ask. # The url is resolved: a short form would follow whatever the default branch
git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.md https://git.hypertheory-labs.dev/jeffry/homelab-cluster/raw/branch/main/.loom/published/gitea.md "530c5bef62bbd325956ed170bb2decf37975e4b9" # is at the time you ask. visibility is what the source could be read as when
git.hypertheory-labs.dev/loom/annotating/annotating.md https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md "9b1f7e6ca92f2339b2d433686c27845362944bdb" # it was fetched, because that is checked once and the copy is durable.
git.hypertheory-labs.dev/loom/bedrock/loom-directory.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/loom-directory.md "ee0f49cb900c0812678061971194325d9cba366a" git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.md https://git.hypertheory-labs.dev/jeffry/homelab-cluster/raw/branch/main/.loom/published/gitea.md "530c5bef62bbd325956ed170bb2decf37975e4b9" public
git.hypertheory-labs.dev/loom/bedrock/publication.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md "385e26ef4108029c12a4267600399da1eece52af" git.hypertheory-labs.dev/loom/annotating/annotating.md https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md "9b1f7e6ca92f2339b2d433686c27845362944bdb" public
git.hypertheory-labs.dev/loom/bedrock/recording-decisions.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/recording-decisions.md "d190eeae7929b9a99ecf09211d94e7e5b32635bd" git.hypertheory-labs.dev/loom/bedrock/loom-directory.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/loom-directory.md "ee0f49cb900c0812678061971194325d9cba366a" public
git.hypertheory-labs.dev/loom/bedrock/sibling-facets.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/sibling-facets.md "a46446a34ccb8bfc533d3cce19f4c88548c4fa04" git.hypertheory-labs.dev/loom/bedrock/publication.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md "c0d9231aa8eed3041c60b72da5735585457fbc90" public
git.hypertheory-labs.dev/loom/bedrock/starting.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md "7d997a30248a88c90b23f2f9453d7cfceb03848e" git.hypertheory-labs.dev/loom/bedrock/recording-decisions.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/recording-decisions.md "d190eeae7929b9a99ecf09211d94e7e5b32635bd" public
git.hypertheory-labs.dev/loom/cart/cart.md https://git.hypertheory-labs.dev/loom/cart/raw/branch/main/.loom/published/cart.md "15331f1a9cc81bf61a44830cfbb7c274f4c2b119" git.hypertheory-labs.dev/loom/bedrock/sibling-facets.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/sibling-facets.md "a46446a34ccb8bfc533d3cce19f4c88548c4fa04" public
git.hypertheory-labs.dev/loom/externals/externals.md https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md "a7586eb52caf275d9bcedbbd8042c43e5aaad0b9" git.hypertheory-labs.dev/loom/bedrock/starting.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md "b9eefba0f4668a496ccfc6a1377277f0721456d2" public
git.hypertheory-labs.dev/loom/cart/cart.md https://git.hypertheory-labs.dev/loom/cart/raw/branch/main/.loom/published/cart.md "15331f1a9cc81bf61a44830cfbb7c274f4c2b119" public
git.hypertheory-labs.dev/loom/externals/externals.md https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md "50673ccffc14d57150ac0a9027b0712d9dcf940d"
@@ -37,6 +37,21 @@ copy without inheriting the visibility it was published under.*
**The test is a fetch you did not perform.** **The test is a fetch you did not perform.**
## Doing it
1. **Decide it is a contract and not a detail.** *This is the whole decision and
the rest is mechanics.*
2. **Move the file into `.loom/published/`.** *One commit, so the diff carries it.*
3. **Say who the readers are***if it is not obvious from where it landed.*
4. **Fetch it.** *From somewhere that is not your machine and not your account.*
5. **Record the decision**, if you keep a log.
**Step 4 is the one that gets skipped and the only one that can fail.** *Steps 1
to 3 are things you do; step 4 is a fact about the world that you find out.*
*Then say what it depends on. A published document that quietly requires a private
one has moved the problem rather than solved it.*
## What is published is a document ## What is published is a document
**Prose, in whatever form its readers need.** *If a repository organises its **Prose, in whatever form its readers need.** *If a repository organises its
@@ -60,13 +60,16 @@ what they came for.*
## Look at one instead of reading this ## Look at one instead of reading this
**There is a worked example — six documents, one gap, no decomposition — and it **[`jeffry/homelab-cluster`](https://git.hypertheory-labs.dev/jeffry/homelab-cluster)** —
is private.** *six documents, one gap, no decomposition.* **It describes what a home cluster
offers to somebody deciding whether to put something on it**, and a reader who
has never heard of any of this still gets what they came for.
*It describes a cluster in enough detail to be a target list, so it is not **It was private, and being unable to link to it is what made the split obvious.**
published, and **this page will not link you to something you cannot fetch.*** *The operational tree is a version inventory and a target list; the pages telling
**If you have access, ask for it by name; if you do not, the two questions at the somebody what to decide are neither.* **So the manifests moved to a private
top are the whole of it.** sibling and the documents stayed** — *and the documents got better for being
written to a stranger, which was not the reason for doing it.*
> **A public page naming a private thing as its canonical answer is worse than no > **A public page naming a private thing as its canonical answer is worse than no
> example**, and this page did exactly that until somebody measured it. > example**, and this page did exactly that until somebody measured it.
@@ -62,6 +62,7 @@ what to decide are not.**
- **where it was fetched from** — *resolved, as above* - **where it was fetched from** — *resolved, as above*
- **the publisher's `ETag`** — *verbatim* - **the publisher's `ETag`** — *verbatim*
- **the visibility the source could be read at, when it was fetched**
**It is committed**, because the thing it locks is committed, and *a lock that **It is committed**, because the thing it locks is committed, and *a lock that
travels separately from what it locks is the drift this is meant to prevent.* travels separately from what it locks is the drift this is meant to prevent.*
@@ -71,6 +72,26 @@ travels separately from what it locks is the drift this is meant to prevent.*
> asserts your copy is the one being served, which is the thing you were going to > asserts your copy is the one being served, which is the thing you were going to
> check. > check.
### Why visibility is in the lock and not left to the moment
**Access is checked once and the copy is durable.** *A token gets you the read;
nothing revisits it afterwards.* **So the adoption's legitimacy rests on the
relative visibility of two repositories — and that is changeable by a checkbox, a
year later, by somebody who never saw the adoption.**
> **Record `public` or `not-public`. Never `private`.** *An anonymous request can
> tell you a repository is not public. It cannot tell two repositories private to
> **different people** apart* — **which is exactly the case where private into
> private genuinely widens access.** *Record the word you can verify.*
**Checking it costs one request per run, not one per document**, *because the only
thing that must be current is **your own** visibility.* **Re-check a source only
when the alarm would fire** — *you are public, it was recorded not-public* — *and
if it has since gone public, update the lock and say nothing.*
*A stored visibility decays in both directions, which is why the alarm re-checks
rather than trusting the record.*
## Freshness is a conditional request ## Freshness is a conditional request
**Locked on the publisher's `ETag`, verbatim — never a hash you compute.** *A **Locked on the publisher's `ETag`, verbatim — never a hash you compute.** *A
+69
View File
@@ -0,0 +1,69 @@
<!-- Generated by `loomctl orient`. Safe to overwrite; nobody typed it.
Regenerate after adopting a document or reconciling a change. -->
# What this repository depends on
Copies of other people's documents are kept under `.loom/externals/`, at a path
that says where each came from. **They are copies: do not edit them.** Anything
you want to say about one goes in a file *beside* it, never into it.
Three moves, and each has a document that owns the rule:
- **A copy is wrong, or you needed something it does not say** — write it in
`<name>.gaps.md` beside the copy.
- **What of ours depends on a copy** — write it in `<name>.usages.md` beside it.
- **A source changed** — `loomctl external check` says so and stages the new copy;
`loomctl external apply` takes it. Neither edits anything on its own.
## `git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.md`
- source: https://git.hypertheory-labs.dev/jeffry/homelab-cluster/raw/branch/main/.loom/published/gitea.md
- what of ours depends on it: `.loom/externals/git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.usages.md`
## `git.hypertheory-labs.dev/loom/annotating/annotating.md`
- source: https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md
- what of ours depends on it: **nothing recorded** — no `.usages.md`
- what we expected and did not find: `.loom/externals/git.hypertheory-labs.dev/loom/annotating/annotating.gaps.md`
## `git.hypertheory-labs.dev/loom/bedrock/loom-directory.md`
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/loom-directory.md
- what of ours depends on it: **nothing recorded** — no `.usages.md`
## `git.hypertheory-labs.dev/loom/bedrock/publication.md`
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md
- what of ours depends on it: **nothing recorded** — no `.usages.md`
## `git.hypertheory-labs.dev/loom/bedrock/recording-decisions.md`
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/recording-decisions.md
- what of ours depends on it: **nothing recorded** — no `.usages.md`
## `git.hypertheory-labs.dev/loom/bedrock/sibling-facets.md`
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/sibling-facets.md
- what of ours depends on it: **nothing recorded** — no `.usages.md`
## `git.hypertheory-labs.dev/loom/bedrock/starting.md`
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md
- what of ours depends on it: **nothing recorded** — no `.usages.md`
## `git.hypertheory-labs.dev/loom/cart/cart.md`
- source: https://git.hypertheory-labs.dev/loom/cart/raw/branch/main/.loom/published/cart.md
- what of ours depends on it: `.loom/externals/git.hypertheory-labs.dev/loom/cart/cart.usages.md`
- what we expected and did not find: `.loom/externals/git.hypertheory-labs.dev/loom/cart/cart.gaps.md`
## `git.hypertheory-labs.dev/loom/externals/externals.md`
- source: https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md
- what of ours depends on it: `.loom/externals/git.hypertheory-labs.dev/loom/externals/externals.usages.md`
## This repository publishes
See `.loom/published/`. What is handed over for others to depend on is
there; the rest of the repository is not hidden, it is simply not what
anybody depends on.
+114
View File
@@ -0,0 +1,114 @@
# What `loomctl` guarantees
**Not what it does — `--help` says that, and the commands will change.** *This is
what will keep being true while they do.*
---
## It grants no access, and records where the copy came from
**`loomctl` reads what your credentials already let you read.** *Everything it
does is possible with copy and paste.* **It is a mast, not a lock** — *the point
is to make the wrong thing deliberate, not impossible.*
> **What it adds over a paste is provenance.** *A pasted document cannot answer
> "where did this come from, and were we allowed to have it" — not because the
> question is hard, but because the evidence is gone.*
## It never writes over the network
**No push, no publish, no `POST`, no token that needs write scope.** *Every byte
it writes is a file in your working tree.* **Committing and pushing are yours**,
*because the consequences of a push land on people a tool cannot experience.*
**A credential given to `loomctl` should never carry write scope**, *and if one
does, nothing here will use it.*
## Freshness is a conditional request against the publisher's `ETag`
**Stored verbatim, opaque, never a hash we compute.** *A fetch that normalises
anything breaks a local digest and reports a change that did not happen.*
*This is `externals`' rule and `loomctl` is a second holder of it. **It is stated
here so that the tool holding it is a fact somebody can find**, and so that
breaking it is visible rather than silent* — **a specimen was written in this
repository that computed a hash instead, and it was wrong within a day.**
**The URL in a lock is resolved.** *A short form follows whatever the default
branch is at the time you ask, so a branch rename would report as a change in the
document.*
## It reports; it does not repair
**Nothing is overwritten.** *A document that moved upstream is written to a
staging area as a candidate, and taking it is a separate act.* **A local copy
that differs from what the publisher serves is left alone** — *it is the only
evidence that something changed while nothing was watching.*
**`add` adopts what is absent and refuses what is already adopted.** *A document
that is present but unlocked is locked only when the bytes are identical to what
the publisher serves*, **so a lock's claim — this copy is the one being served —
is verified rather than assumed.**
## The lock file
**`.loom/externals/.locks`, one record per adopted document, tab-separated,
ordered by path.**
```
path <TAB> url <TAB> etag [ <TAB> visibility ]
```
- **`path`** — *relative to `.loom/externals/`, and **for a person to read**.* **It
does not round-trip to a URL**; *it drops the route, the branch, and the
publisher's `.loom/published/`.*
- **`url`** — *the resolved origin, branch and all.*
- **`etag`** — *the publisher's, verbatim, including its quotes.*
- **`visibility`** — *optional. What the source could be read as **when it was
fetched**: `public` or `not-public`.* **Never `private`** — *an anonymous
request tells those two apart and nothing finer.*
**Records with three fields remain valid.** *A repository does not stop working
because the tool learned something new.*
## What is not promised
**The command surface.** *Names, flags and output are `--help`'s business and may
change. Nothing should parse them.*
**The generated orientation file's format.** *It is byte-deterministic within a
version so that its diff is readable; it is not stable across versions.*
**That visibility is precise.** *The signal distinguishes public from not-public
and nothing finer, so it cannot see two repositories private to different people
— which is the case where adopting between private repositories genuinely widens
access.* **The tool says so where it reports it, rather than implying a verdict it
has not earned.**
**That anything is checked when you are not looking.** *Nothing here runs on a
schedule, and a document nobody checks is a document nobody is checking.*
## What is promised about the things that are not promised
**The command surface may change. This page will say when it does.**
*A consumer whose README tells a person to type `loomctl external add loom/<repo>
<doc>.md` depends on that command by name — **not by parsing it, which is why no
check would ever catch a rename**. The thing that moved would not be a document
they adopted.*
> **So the exclusion is narrower than "you are on your own".** *We do not promise
> the surface holds; **we promise that a change to it lands here**, on a page you
> can lock* — **and a lock on this page reports `200` the day a command is
> renamed.**
**That is the whole mechanism, and it costs one section:**
### Surface changes
*Append-only, newest last. Nothing yet — the commands have not been renamed since
this page existed.*
---
*Verified fetchable by somebody who is not us on 2026-09-08.*
+25 -1
View File
@@ -2,6 +2,7 @@ package external
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"os/exec" "os/exec"
@@ -50,7 +51,30 @@ func AddByName(root, ownerRepo, name string, out io.Writer) error {
} }
raw = fmt.Sprintf(raw, branch) raw = fmt.Sprintf(raw, branch)
} }
return Add(root, raw, "", out) err = Add(root, raw, "", out)
// A 404 here has a reading the convention does not list, because it can only
// happen at adoption: the name is wrong. We know the repository, so rather
// than reporting an ambiguity we can resolve, look.
var nf *NotFoundError
if errors.As(err, &nf) {
var names strings.Builder
if lerr := List(ctx.CloneURL(ownerRepo), &names); lerr == nil {
return fmt.Errorf("%s publishes no %q. It publishes:\n%s",
ownerRepo, name, indent(names.String()))
}
// Listing failed too, so the repository itself is unreachable and the
// original ambiguity stands.
}
return err
}
func indent(s string) string {
var b strings.Builder
for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") {
fmt.Fprintf(&b, " %s\n", line)
}
return strings.TrimRight(b.String(), "\n")
} }
// ListByName enumerates a repository named against the current context. // ListByName enumerates a repository named against the current context.
+68
View File
@@ -0,0 +1,68 @@
package external
import (
"fmt"
"io"
"git.hypertheory-labs.dev/loom/loom-cli/internal/lock"
)
// auditExposure reports documents adopted from a source that could not be read
// anonymously, into a repository that now can.
//
// Access is verified once, at fetch, and the copy is durable. Whether the
// adoption is still legitimate rests on the relative visibility of two
// repositories — a fact somebody can change with a checkbox a year later,
// without ever seeing the adoption. This is what turns that from a silent
// permanent hazard into something that runs.
func auditExposure(root string, locks *lock.Set, out io.Writer) (changed bool) {
var suspect []lock.Record
for _, r := range locks.All() {
if r.Visibility == lock.NotPublic {
suspect = append(suspect, r)
}
}
if len(suspect) == 0 {
return false
}
// Only our own visibility has to be current, and it is one request for the
// whole run rather than one per document.
public, known := selfVisibility(root)
if known && !public {
return false // adopted private into private; nothing has widened
}
if !known {
fmt.Fprintf(out, "\n%d document(s) came from a source that needed a credential, and I cannot\n", len(suspect))
fmt.Fprintf(out, "tell who may read this repository — no usable origin.\n")
return false
}
// The stored value decays in both directions. A source that has since gone
// public would otherwise raise this alarm forever, so re-check — but only
// the suspects, and only when the alarm would actually fire.
var still []lock.Record
for _, r := range suspect {
// Probe with no credential: what matters is what a stranger can read,
// not what we can.
if probeAnonymous(r.URL) == lock.Public {
r.Visibility = lock.Public
locks.Put(r)
changed = true
continue
}
still = append(still, r)
}
if len(still) == 0 {
return changed
}
fmt.Fprintf(out, "\nEXPOSURE this repository is public and holds %d document(s) adopted from\n", len(still))
fmt.Fprintf(out, " sources that are not:\n")
for _, r := range still {
fmt.Fprintf(out, " %s\n", r.Path)
}
fmt.Fprintf(out, " Confidentiality does not travel with the copy. This was legitimate\n")
fmt.Fprintf(out, " when adopted if this repository was not public then.\n")
return changed
}
+56 -17
View File
@@ -119,6 +119,7 @@ func Add(root, raw, override string, out io.Writer) error {
if body == nil { if body == nil {
return fmt.Errorf("%s: unexpected 304 for a document we do not have", raw) return fmt.Errorf("%s: unexpected 304 for a document we do not have", raw)
} }
vis := sourceVisibility(cfg, body.url)
// A document that is here but unlocked was fetched by hand before the tool // A document that is here but unlocked was fetched by hand before the tool
// existed. Supplying its URL is the only way it can ever be locked, because // existed. Supplying its URL is the only way it can ever be locked, because
@@ -145,7 +146,7 @@ func Add(root, raw, override string, out io.Writer) error {
} }
// Identical, so the assertion a lock makes — this local copy is the one // Identical, so the assertion a lock makes — this local copy is the one
// being served — is verified rather than assumed. // being served — is verified rather than assumed.
locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag}) locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag, Visibility: vis})
if err := locks.Save(); err != nil { if err := locks.Save(); err != nil {
return err return err
} }
@@ -161,14 +162,14 @@ func Add(root, raw, override string, out io.Writer) error {
if err := os.WriteFile(dest, body.data, 0o644); err != nil { if err := os.WriteFile(dest, body.data, 0o644); err != nil {
return err return err
} }
locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag}) locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag, Visibility: vis})
if err := locks.Save(); err != nil { if err := locks.Save(); err != nil {
return err return err
} }
fmt.Fprintf(out, "adopted %s\n", rel) fmt.Fprintf(out, "adopted %s\n", rel)
fmt.Fprintf(out, " from %s\n", body.url) fmt.Fprintf(out, " from %s\n", body.url)
warnIfNotPublic(cfg, root, body.url, out) warnIfNotPublic(root, body.url, vis, out)
notePublishedSurface(body.url, out) notePublishedSurface(body.url, out)
if etag == "" { if etag == "" {
fmt.Fprintf(out, " etag (none served — check cannot ask conditionally)\n") fmt.Fprintf(out, " etag (none served — check cannot ask conditionally)\n")
@@ -186,26 +187,50 @@ func Add(root, raw, override string, out io.Writer) error {
// moment of adoption. The tool can see half of that — whether this fetch needed // moment of adoption. The tool can see half of that — whether this fetch needed
// a credential — and cannot see the other half, which is who can read the // a credential — and cannot see the other half, which is who can read the
// repository the copy is landing in. It reports the half it knows. // repository the copy is landing in. It reports the half it knows.
func warnIfNotPublic(cfg *config.Config, root, raw string, out io.Writer) { // sourceVisibility reports what a document could be read as at the moment it was
// fetched, given that the fetch had just succeeded.
//
// It is free: when no credential was configured the fetch itself was anonymous,
// so the answer is already known; when one was, the extra request is the one the
// warning needed anyway.
//
// It must not be used to re-check a document we are not fetching. "No credential
// configured" says nothing about whether a probe would succeed, and treating it
// as public there would silently clear a real alarm.
func sourceVisibility(cfg *config.Config, raw string) string {
if u, err := url.Parse(raw); err == nil && cfg.TokenFor(u.Host) == "" {
return lock.Public // it came back without a credential
}
return probeAnonymous(raw)
}
// probeAnonymous asks, with no credential at all, whether a URL can be read.
func probeAnonymous(raw string) string {
req, err := http.NewRequest(http.MethodHead, raw, nil) req, err := http.NewRequest(http.MethodHead, raw, nil)
if err != nil { if err != nil {
return return ""
}
if cfg.TokenFor(req.URL.Host) == "" {
return // no credential was used, so the fetch was already anonymous
} }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return return ""
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode == http.StatusOK { if resp.StatusCode == http.StatusOK {
return lock.Public
}
return lock.NotPublic
}
// warnIfNotPublic says something when adopting would widen who can read a
// document.
//
// Adopting is copying, so a document from a repository somebody may not read
// ends up in a repository they may, and the publisher loses control of it at the
// moment of adoption.
func warnIfNotPublic(root, raw, vis string, out io.Writer) {
if vis != lock.NotPublic {
return return
} }
// The source is private. Whether that matters depends on where it is
// landing, and a warning on every adoption a private repository performs is
// noise in exactly the workflow that is legitimate.
public, known := selfVisibility(root) public, known := selfVisibility(root)
if known && !public { if known && !public {
fmt.Fprintf(out, " NOTE private source, and this repository is not public either.\n") fmt.Fprintf(out, " NOTE private source, and this repository is not public either.\n")
@@ -219,8 +244,7 @@ func warnIfNotPublic(cfg *config.Config, root, raw string, out io.Writer) {
fmt.Fprintf(out, " repository — no usable origin. Check before you commit.\n") fmt.Fprintf(out, " repository — no usable origin. Check before you commit.\n")
return return
} }
fmt.Fprintf(out, " WARN this needed a credential anonymously it is %s — and THIS\n", resp.Status) fmt.Fprintf(out, " WARN this needed a credential, and THIS repository is public.\n")
fmt.Fprintf(out, " repository is public.\n")
fmt.Fprintf(out, " Confidentiality does not travel with the copy: adopting this\n") fmt.Fprintf(out, " Confidentiality does not travel with the copy: adopting this\n")
fmt.Fprintf(out, " publishes it to everyone. Do not adopt from a source less\n") fmt.Fprintf(out, " publishes it to everyone. Do not adopt from a source less\n")
fmt.Fprintf(out, " readable than the repository you are adopting into.\n") fmt.Fprintf(out, " readable than the repository you are adopting into.\n")
@@ -229,6 +253,19 @@ func warnIfNotPublic(cfg *config.Config, root, raw string, out io.Writer) {
fmt.Fprintf(out, " record only the dependency, which loomctl cannot do yet.\n") fmt.Fprintf(out, " record only the dependency, which loomctl cannot do yet.\n")
} }
// NotFoundError is a 404, which over HTTP carries more than one reading.
//
// The convention names two — withdrawn, or access lost — because those are the
// two a locked document can have. Adoption by name has a third: a document that
// was never there under that name. Callers that know the repository can tell
// them apart; this type is how they get the chance.
type NotFoundError struct{ URL string }
func (e *NotFoundError) Error() string {
return fmt.Sprintf("404 unresolved: %s was withdrawn, or this credential cannot see it — "+
"over HTTP these are the same response", e.URL)
}
type fetched struct { type fetched struct {
data []byte data []byte
url string url string
@@ -257,8 +294,7 @@ func fetch(cfg *config.Config, raw, ifNoneMatch string) (*fetched, string, error
} }
return &fetched{data: b, url: resp.Request.URL.String()}, resp.Header.Get("ETag"), nil return &fetched{data: b, url: resp.Request.URL.String()}, resp.Header.Get("ETag"), nil
case http.StatusNotFound: case http.StatusNotFound:
return nil, "", fmt.Errorf("404 unresolved: %s was withdrawn, or this credential cannot see it — "+ return nil, "", &NotFoundError{URL: raw}
"over HTTP these are the same response", raw)
default: default:
return nil, "", fmt.Errorf("%s: %s", resp.Status, raw) return nil, "", fmt.Errorf("%s: %s", resp.Status, raw)
} }
@@ -307,6 +343,8 @@ func Check(root string, out io.Writer) error {
staged += did staged += did
results = append(results, st) results = append(results, st)
} }
auditPrinted := &strings.Builder{}
auditExposure(root, locks, auditPrinted)
if err := locks.Save(); err != nil { if err := locks.Save(); err != nil {
return err return err
} }
@@ -327,6 +365,7 @@ func Check(root string, out io.Writer) error {
if staged > 0 { if staged > 0 {
fmt.Fprintf(out, "\n%d staged in %s — apply or discard; nothing here drifts into being kept.\n", staged, PoladDir) fmt.Fprintf(out, "\n%d staged in %s — apply or discard; nothing here drifts into being kept.\n", staged, PoladDir)
} }
io.WriteString(out, auditPrinted.String())
return nil return nil
} }
+36 -6
View File
@@ -25,8 +25,10 @@ const Dir = ".loom/externals"
const File = ".locks" const File = ".locks"
const header = "# loomctl locks — one record per adopted document.\n" + const header = "# loomctl locks — one record per adopted document.\n" +
"# path<TAB>url<TAB>etag The url is resolved: a short form would follow\n" + "# path<TAB>url<TAB>etag[<TAB>visibility]\n" +
"# whatever the default branch is at the time you ask.\n" "# The url is resolved: a short form would follow whatever the default branch\n" +
"# is at the time you ask. visibility is what the source could be read as when\n" +
"# it was fetched, because that is checked once and the copy is durable.\n"
// Record is one adopted document. // Record is one adopted document.
type Record struct { type Record struct {
@@ -38,8 +40,28 @@ type Record struct {
URL string URL string
// ETag is the publisher's, verbatim, including its quotes. // ETag is the publisher's, verbatim, including its quotes.
ETag string ETag string
// Visibility is what the source could be read as when it was fetched:
// "public", "not-public", or empty for locks written before this was
// recorded.
//
// It is deliberately coarse. An anonymous request tells public from
// not-public and nothing finer, so this cannot distinguish two repositories
// private to different people — which is the case where adopting between
// private repositories genuinely widens access.
//
// It is recorded because access is checked once, at fetch, and the copy is
// durable. Whether an adoption is still legitimate depends on the relative
// visibility of two repositories, which somebody can change with a checkbox
// a year later without ever seeing the adoption.
Visibility string
} }
// Visibility values. Never "private": the signal cannot support the word.
const (
Public = "public"
NotPublic = "not-public"
)
// Set is every lock, keyed by path. // Set is every lock, keyed by path.
type Set struct { type Set struct {
file string file string
@@ -75,10 +97,14 @@ func LoadFile(file string) (*Set, error) {
continue continue
} }
parts := strings.Split(line, "\t") parts := strings.Split(line, "\t")
if len(parts) != 3 { if len(parts) != 3 && len(parts) != 4 {
return nil, fmt.Errorf("%s:%d: want 3 tab-separated fields, got %d", file, n, len(parts)) return nil, fmt.Errorf("%s:%d: want 3 or 4 tab-separated fields, got %d", file, n, len(parts))
} }
s.recs[parts[0]] = Record{Path: parts[0], URL: parts[1], ETag: parts[2]} r := Record{Path: parts[0], URL: parts[1], ETag: parts[2]}
if len(parts) == 4 {
r.Visibility = parts[3]
}
s.recs[parts[0]] = r
} }
return s, sc.Err() return s, sc.Err()
} }
@@ -112,7 +138,11 @@ func (s *Set) Save() error {
var b strings.Builder var b strings.Builder
b.WriteString(header) b.WriteString(header)
for _, r := range s.All() { for _, r := range s.All() {
fmt.Fprintf(&b, "%s\t%s\t%s\n", r.Path, r.URL, r.ETag) if r.Visibility == "" {
fmt.Fprintf(&b, "%s\t%s\t%s\n", r.Path, r.URL, r.ETag)
continue
}
fmt.Fprintf(&b, "%s\t%s\t%s\t%s\n", r.Path, r.URL, r.ETag, r.Visibility)
} }
tmp, err := os.CreateTemp(filepath.Dir(p), ".locks-*") tmp, err := os.CreateTemp(filepath.Dir(p), ".locks-*")
if err != nil { if err != nil {
+57
View File
@@ -0,0 +1,57 @@
package lock
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Locks written before visibility was recorded have three fields, and must keep
// loading: a repository does not get to stop working because the tool learned
// something new.
func TestLoadsThreeAndFourFieldRecords(t *testing.T) {
root := t.TempDir()
p := Path(root)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
body := "# comment\n\nh/o/r/old.md\thttps://h/old\t\"1\"\n" +
"h/o/r/new.md\thttps://h/new\t\"2\"\tnot-public\n"
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
s, err := Load(root)
if err != nil {
t.Fatal(err)
}
old, _ := s.Get("h/o/r/old.md")
if old.Visibility != "" {
t.Errorf("a three-field record should have unknown visibility, got %q", old.Visibility)
}
nw, _ := s.Get("h/o/r/new.md")
if nw.Visibility != NotPublic {
t.Errorf("visibility = %q, want %q", nw.Visibility, NotPublic)
}
// And unknown must survive a round trip rather than being written as a value.
if err := s.Save(); err != nil {
t.Fatal(err)
}
out, _ := os.ReadFile(p)
for _, line := range strings.Split(string(out), "\n") {
if strings.HasPrefix(line, "h/o/r/old.md") && strings.Count(line, "\t") != 2 {
t.Errorf("unknown visibility was written as a field: %q", line)
}
}
}
func TestRejectsAMalformedRecord(t *testing.T) {
root := t.TempDir()
p := Path(root)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte("h/o/r/x.md\thttps://h/x\n"), 0o644)
if _, err := Load(root); err == nil {
t.Error("a two-field record should be an error, not a silently empty ETag")
}
}
+109
View File
@@ -0,0 +1,109 @@
// Package orient generates a table of contents over what a repository depends
// on, for whoever arrives next — a person, or an agent of any make.
//
// 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: this file
// says only what is here, where it came from, and where the rules live.
//
// The output is byte-deterministic. A generated file that churns produces diffs
// nobody reads, and the diff is most of the value — it is how somebody sees that
// their dependencies moved.
package orient
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
"git.hypertheory-labs.dev/loom/loom-cli/internal/lock"
)
// File is where the orientation lives, relative to the repository root.
//
// Beside .loom/event-log.md rather than inside .loom/externals/, because
// everything in that directory is somebody else's document — which is what makes
// "do not edit these" a rule you can state in four words — and because `check`
// walks it and would report a generated file as an unlocked external forever.
const File = ".loom/orientation.md"
const preamble = `<!-- Generated by ` + "`loomctl orient`" + `. Safe to overwrite; nobody typed it.
Regenerate after adopting a document or reconciling a change. -->
# What this repository depends on
Copies of other people's documents are kept under ` + "`.loom/externals/`" + `, at a path
that says where each came from. **They are copies: do not edit them.** Anything
you want to say about one goes in a file *beside* it, never into it.
Three moves, and each has a document that owns the rule:
- **A copy is wrong, or you needed something it does not say** — write it in
` + "`<name>.gaps.md`" + ` beside the copy.
- **What of ours depends on a copy** — write it in ` + "`<name>.usages.md`" + ` beside it.
- **A source changed** — ` + "`loomctl external check`" + ` says so and stages the new copy;
` + "`loomctl external apply`" + ` takes it. Neither edits anything on its own.
`
// Generate writes the orientation for the repository at root.
func Generate(root, out string) (string, error) {
locks, err := lock.Load(root)
if err != nil {
return "", err
}
var b strings.Builder
b.WriteString(preamble)
recs := locks.All() // already ordered by path
if len(recs) == 0 {
b.WriteString("\nThis repository adopts nothing yet.\n")
}
for _, r := range recs {
fmt.Fprintf(&b, "\n## `%s`\n\n", r.Path)
fmt.Fprintf(&b, "- source: %s\n", r.URL)
facets := facetsFor(root, r.Path)
if u, ok := facets["usages"]; ok {
fmt.Fprintf(&b, "- what of ours depends on it: `%s`\n", u)
} else {
fmt.Fprintf(&b, "- what of ours depends on it: **nothing recorded** — no `.usages.md`\n")
}
if g, ok := facets["gaps"]; ok {
fmt.Fprintf(&b, "- what we expected and did not find: `%s`\n", g)
}
}
if dir := filepath.Join(root, ".loom", "published"); isDir(dir) {
b.WriteString("\n## This repository publishes\n\n")
b.WriteString("See `.loom/published/`. What is handed over for others to depend on is\n")
b.WriteString("there; the rest of the repository is not hidden, it is simply not what\n")
b.WriteString("anybody depends on.\n")
}
dest := out
if dest == "" {
dest = filepath.Join(root, filepath.FromSlash(File))
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", err
}
return dest, os.WriteFile(dest, []byte(b.String()), 0o644)
}
// facetsFor finds the files written beside an adopted document.
func facetsFor(root, rel string) map[string]string {
found := map[string]string{}
stem := strings.TrimSuffix(rel, path.Ext(rel))
for _, kind := range []string{"usages", "gaps"} {
p := stem + "." + kind + ".md"
if _, err := os.Stat(filepath.Join(root, lock.Dir, filepath.FromSlash(p))); err == nil {
found[kind] = lock.Dir + "/" + p
}
}
return found
}
func isDir(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
+73
View File
@@ -0,0 +1,73 @@
package orient
import (
"os"
"path/filepath"
"strings"
"testing"
"git.hypertheory-labs.dev/loom/loom-cli/internal/lock"
)
// A generated file that churns produces diffs nobody reads, and the diff is most
// of the value.
func TestGenerateIsDeterministic(t *testing.T) {
root := t.TempDir()
locks, err := lock.LoadFile(lock.Path(root))
if err != nil {
t.Fatal(err)
}
// Inserted out of order on purpose: the output must not depend on it.
for _, r := range []lock.Record{
{Path: "h/o/zeta/z.md", URL: "https://h/z", ETag: `"3"`},
{Path: "h/o/alpha/a.md", URL: "https://h/a", ETag: `"1"`},
{Path: "h/o/mid/m.md", URL: "https://h/m", ETag: `"2"`},
} {
locks.Put(r)
}
if err := locks.Save(); err != nil {
t.Fatal(err)
}
var first string
for i := 0; i < 3; i++ {
dest, err := Generate(root, filepath.Join(root, "out.md"))
if err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if i == 0 {
first = string(b)
continue
}
if string(b) != first {
t.Fatal("output changed between runs")
}
}
if a, z := strings.Index(first, "alpha"), strings.Index(first, "zeta"); a > z {
t.Error("entries are not ordered by path")
}
if strings.Contains(first, "publishes") {
t.Error("claimed the repository publishes with no .loom/published")
}
}
func TestSaysWhenNothingRecordsADependency(t *testing.T) {
root := t.TempDir()
locks, _ := lock.LoadFile(lock.Path(root))
locks.Put(lock.Record{Path: "h/o/r/doc.md", URL: "https://h/doc", ETag: `"1"`})
if err := locks.Save(); err != nil {
t.Fatal(err)
}
dest, err := Generate(root, filepath.Join(root, "out.md"))
if err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(dest)
if !strings.Contains(string(b), "nothing recorded") {
t.Error("a document with no .usages.md should say so — it is a finding, not an omission")
}
}
+18
View File
@@ -17,6 +17,7 @@ import (
"git.hypertheory-labs.dev/loom/loom-cli/internal/config" "git.hypertheory-labs.dev/loom/loom-cli/internal/config"
"git.hypertheory-labs.dev/loom/loom-cli/internal/external" "git.hypertheory-labs.dev/loom/loom-cli/internal/external"
"git.hypertheory-labs.dev/loom/loom-cli/internal/orient"
) )
const usage = `loomctl — fetch what you depend on, and find out when it changed. const usage = `loomctl — fetch what you depend on, and find out when it changed.
@@ -50,6 +51,7 @@ Adopted documents live in .loom/externals/<host>/<owner>/<repo>/<name>.md, and
their origins in .loom/externals/.locks. The path is for a person to read; the their origins in .loom/externals/.locks. The path is for a person to read; the
lock is what a machine uses, because the path does not round-trip to a URL. lock is what a machine uses, because the path does not round-trip to a URL.
loomctl orient [--out path] write .loom/orientation.md for whoever arrives next
loomctl config which context is current, and from where loomctl config which context is current, and from where
A bare owner/repo is resolved against the current context in A bare owner/repo is resolved against the current context in
@@ -87,6 +89,22 @@ func run(args []string) error {
switch args[0] { switch args[0] {
case "external": case "external":
return runExternal(args[1:]) return runExternal(args[1:])
case "orient":
fs := flag.NewFlagSet("orient", flag.ContinueOnError)
out := fs.String("out", "", "write here instead of "+orient.File)
if err := fs.Parse(args[1:]); err != nil {
return err
}
root, err := root()
if err != nil {
return err
}
dest, err := orient.Generate(root, *out)
if err != nil {
return err
}
fmt.Fprintf(os.Stdout, "wrote %s\n", dest)
return nil
case "config": case "config":
return showConfig(os.Stdout) return showConfig(os.Stdout)
default: default: