Compare commits
7
Commits
da6e8b9b51
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7b169b87e | ||
|
|
a5093c1efc | ||
|
|
045924ec36 | ||
|
|
d3fec24bc6 | ||
|
|
4e7aa1ff1f | ||
|
|
af8f739bfe | ||
|
|
b3617ed195 |
+212
-74
@@ -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`
|
||||
|
||||
**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
|
||||
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`
|
||||
|
||||
**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
|
||||
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`
|
||||
|
||||
**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
|
||||
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`
|
||||
|
||||
**`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
|
||||
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.*
|
||||
|
||||
Vendored
+13
-11
@@ -1,12 +1,14 @@
|
||||
# loomctl locks — one record per adopted document.
|
||||
# path<TAB>url<TAB>etag The url is resolved: a short form would follow
|
||||
# whatever the default branch is at the time you ask.
|
||||
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"
|
||||
git.hypertheory-labs.dev/loom/annotating/annotating.md https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md "9b1f7e6ca92f2339b2d433686c27845362944bdb"
|
||||
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/loom/bedrock/publication.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md "c0d9231aa8eed3041c60b72da5735585457fbc90"
|
||||
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/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/starting.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md "b9eefba0f4668a496ccfc6a1377277f0721456d2"
|
||||
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/externals/externals.md https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md "a7586eb52caf275d9bcedbbd8042c43e5aaad0b9"
|
||||
# path<TAB>url<TAB>etag[<TAB>visibility]
|
||||
# The url is resolved: a short form would follow whatever the default branch
|
||||
# is at the time you ask. visibility is what the source could be read as when
|
||||
# it was fetched, because that is checked once and the copy is durable.
|
||||
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/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/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/publication.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md "c0d9231aa8eed3041c60b72da5735585457fbc90" 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" 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" public
|
||||
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"
|
||||
|
||||
@@ -62,6 +62,7 @@ what to decide are not.**
|
||||
|
||||
- **where it was fetched from** — *resolved, as above*
|
||||
- **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
|
||||
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
|
||||
> 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
|
||||
|
||||
**Locked on the publisher's `ETag`, verbatim — never a hash you compute.** *A
|
||||
|
||||
@@ -61,3 +61,9 @@ Three moves, and each has a document that owns the rule:
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -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.*
|
||||
Vendored
+25
-1
@@ -2,6 +2,7 @@ package external
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
@@ -50,7 +51,30 @@ func AddByName(root, ownerRepo, name string, out io.Writer) error {
|
||||
}
|
||||
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.
|
||||
|
||||
Vendored
+68
@@ -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
|
||||
}
|
||||
Vendored
+56
-17
@@ -119,6 +119,7 @@ func Add(root, raw, override string, out io.Writer) error {
|
||||
if body == nil {
|
||||
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
|
||||
// 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
|
||||
// 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 {
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "adopted %s\n", rel)
|
||||
fmt.Fprintf(out, " from %s\n", body.url)
|
||||
warnIfNotPublic(cfg, root, body.url, out)
|
||||
warnIfNotPublic(root, body.url, vis, out)
|
||||
notePublishedSurface(body.url, out)
|
||||
if etag == "" {
|
||||
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
|
||||
// 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.
|
||||
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)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if cfg.TokenFor(req.URL.Host) == "" {
|
||||
return // no credential was used, so the fetch was already anonymous
|
||||
return ""
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return
|
||||
return lock.Public
|
||||
}
|
||||
return lock.NotPublic
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
}
|
||||
public, known := selfVisibility(root)
|
||||
if known && !public {
|
||||
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")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, " WARN this needed a credential — anonymously it is %s — and THIS\n", resp.Status)
|
||||
fmt.Fprintf(out, " repository is public.\n")
|
||||
fmt.Fprintf(out, " WARN this needed a credential, and THIS repository is public.\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, " 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")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
data []byte
|
||||
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
|
||||
case http.StatusNotFound:
|
||||
return nil, "", fmt.Errorf("404 unresolved: %s was withdrawn, or this credential cannot see it — "+
|
||||
"over HTTP these are the same response", raw)
|
||||
return nil, "", &NotFoundError{URL: raw}
|
||||
default:
|
||||
return nil, "", fmt.Errorf("%s: %s", resp.Status, raw)
|
||||
}
|
||||
@@ -307,6 +343,8 @@ func Check(root string, out io.Writer) error {
|
||||
staged += did
|
||||
results = append(results, st)
|
||||
}
|
||||
auditPrinted := &strings.Builder{}
|
||||
auditExposure(root, locks, auditPrinted)
|
||||
if err := locks.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -327,6 +365,7 @@ func Check(root string, out io.Writer) error {
|
||||
if staged > 0 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+35
-5
@@ -25,8 +25,10 @@ const Dir = ".loom/externals"
|
||||
const File = ".locks"
|
||||
|
||||
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" +
|
||||
"# whatever the default branch is at the time you ask.\n"
|
||||
"# path<TAB>url<TAB>etag[<TAB>visibility]\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.
|
||||
type Record struct {
|
||||
@@ -38,8 +40,28 @@ type Record struct {
|
||||
URL string
|
||||
// ETag is the publisher's, verbatim, including its quotes.
|
||||
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.
|
||||
type Set struct {
|
||||
file string
|
||||
@@ -75,10 +97,14 @@ func LoadFile(file string) (*Set, error) {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "\t")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("%s:%d: want 3 tab-separated fields, got %d", file, n, len(parts))
|
||||
if len(parts) != 3 && len(parts) != 4 {
|
||||
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()
|
||||
}
|
||||
@@ -112,7 +138,11 @@ func (s *Set) Save() error {
|
||||
var b strings.Builder
|
||||
b.WriteString(header)
|
||||
for _, r := range s.All() {
|
||||
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-*")
|
||||
if err != nil {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user