Jeff's correction breaks published check rather than denting it. homelab-cluster publishes six documents and is private deliberately; publication.md says others, not everyone, and I read one word as the other. The tool as logged would have reported a correct repository as broken, so a superseding entry is added rather than revising the osprey entry. What survives is that bedrock's public page promising a private repository as the better answer was a real defect, and it was never about homelab-cluster. So published check must not report a verdict. It reports the identity it used, and warns when that identity is the repository's own owner — publishing is not an act you can complete alone, and a tool that verifies with your own credential has helped you complete it alone and told you it was fine. Also corrects me: I confirmed the specimen's token claim enthusiastically without testing the case that would show it wrong. A refused write names the token's scopes in the error body, so a token cannot be asked what it may do but can be told by being refused. Verified that no other channel exists — the tokens endpoint rejects token auth, and no scope header comes back on success. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
360 lines
15 KiB
Markdown
360 lines
15 KiB
Markdown
# claude — cart `marmalade`
|
|
|
|
**`osprey` converted, so this is a new round.** *Its name is meaningless and I
|
|
picked it arbitrarily; change it if you like.*
|
|
|
|
---
|
|
|
|
## Two mechanical things before the design
|
|
|
|
**Your text arrived as `jeff-osprey.md` and I have moved it, unedited, to
|
|
`loom-marmalade.md`.** *`cart` allows two files and gives the test:* **what
|
|
happens to this file when the person changes?** *`jeff-…` dies with Jeff, so it is
|
|
a person and not a stake* — **and loom renamed itself to `loom` last round for
|
|
exactly this reason**, *saying that if you wrote here you would write into that
|
|
file.* **Not a correction of you; the shape predicted it and the shape was right.**
|
|
|
|
**And it landed in a cart that had converted**, *which is fine — it opened the next
|
|
one.* **Nothing travelled silently:** *everything below is reintroduced with a
|
|
fresh reason, and the design that survived `osprey` is in
|
|
[`.loom/event-log.md`](../../event-log.md) rather than in a file I am carrying
|
|
forward.*
|
|
|
|
## `loomctl`, and the noun that is missing
|
|
|
|
**Subcommands, yes.** *And your grouping exposes something the log got wrong.*
|
|
|
|
**`osprey` named three commands — `pull`, `check`, `reachable` — as if they were
|
|
one family. They are two.**
|
|
|
|
```
|
|
loomctl external add | check things somebody else published, that we depend on
|
|
loomctl published check things we published, that somebody else depends on
|
|
```
|
|
|
|
> **`reachable` is not an `external` operation at all.** *It looks at
|
|
> `.loom/published/`, which is our surface and not anybody's dependency.* **Under
|
|
> your grouping it has no home, and that is the grouping doing its job.**
|
|
|
|
*`publication.md` already draws this line — **published is a change of kind** — and
|
|
the CLI having a noun for each side is the same distinction wearing a verb.*
|
|
|
|
**I would drop the word `reachable` and make it `loomctl published check`**, *so
|
|
that `check` means one thing in both nouns: **ask somebody else whether what we
|
|
believe is still true.*** *Against an external it asks the publisher; against our
|
|
own, it asks as a stranger.*
|
|
|
|
## `external list` is the first command that needs to know what a host is
|
|
|
|
**Everything in `osprey` is host-agnostic on purpose.** *A conditional `GET`
|
|
behaves identically on gitea and GitHub — we measured it.* **`list` is not like
|
|
that.** *Enumerating `.loom/published/` means asking a service what is in a
|
|
directory, and every host answers differently or not at all.*
|
|
|
|
**That is the real cost of the command, and I think it is worth paying**, because
|
|
of what it buys:
|
|
|
|
- **The origin path stops being parsed.** *You type a repository and a document
|
|
name; the tool builds the URL and writes it to the lock.* **The problem that ate
|
|
two passes of `osprey` disappears by never arising.**
|
|
- **It is discovery**, *which the log records as the thing a fetcher cannot do* —
|
|
**and your `gitea.md` story is exactly the failure it fixes.** *I could not have
|
|
pulled that document, because I did not know it existed.*
|
|
|
|
> **You said there is no registry yet, so you are the registry.** *`external list`
|
|
> is the smallest thing that stops that being true.*
|
|
|
|
## Select: what does `external list` talk to?
|
|
|
|
- **each host's REST API** — *gitea `/api/v1/repos/{o}/{r}/contents/.loom/published`,
|
|
GitHub `/repos/{o}/{r}/contents/…`, one adapter per host*
|
|
- **git itself** — *a treeless or shallow fetch, then `ls-tree` the published
|
|
directory; the same protocol everywhere, no adapters, no API tokens*
|
|
- **a published index** — *the convention grows a file listing the surface, and
|
|
`list` is a fetch like every other command*
|
|
|
|
*My lean is **git itself**, and I want to be honest that it is not obviously
|
|
right.* **git is the one API every one of these hosts already speaks**, *and we
|
|
have working ssh here* — **so `list` needs no host adapter and no second auth
|
|
story**, *which collapses most of your `auth` section.* **The costs are real:** *it
|
|
is heavier than one request, it needs git credentials rather than a read token,
|
|
and it fetches more than it needs to answer.*
|
|
|
|
**Fallback if unanswered:** *I build it against git, behind a small seam, and log
|
|
that the REST adapter is the fallback if the git path is too slow or too
|
|
credentialed for a public repository.*
|
|
|
|
## Auth: kubeconfig-shaped is right, and it is not only credentials
|
|
|
|
**Your instinct holds for a reason the specimen missed.** *`osprey` assumed one
|
|
token because it assumed one host.* **The moment there are several, the per-host
|
|
thing is not just a secret — it is *how you talk to this host at all***: *its API
|
|
shape, whether ssh is on a strange port, which transport `check` should use.*
|
|
|
|
**That is a config, and kubeconfig is the right silhouette:** *named contexts, one
|
|
current, everything addressable by name.*
|
|
|
|
**Two things I would carry forward from `osprey` into it:**
|
|
|
|
**A token cannot be asked what it may do** — *gitea's `/api/v1/repos/…` reports the
|
|
**account's** rights, not the token's* — **so the name is the only record of its
|
|
scope**, and the config should make naming unavoidable rather than optional.
|
|
|
|
**And it lives in `~`, never in a repository.** *A file that holds hosts and
|
|
tokens together will be tempting to commit the first time two people need the same
|
|
host list.* **Splitting it — a committed host list, a private credential file — is
|
|
the obvious answer and I have not thought it through**, *so I am naming it rather
|
|
than deciding it.*
|
|
|
|
## Affirm: subcommands now, plugins not yet?
|
|
|
|
**I would not build a plugin boundary before `beads` exists.**
|
|
|
|
*`kubectl` and `gh` both grew plugins after they had users and after somebody
|
|
outside wanted in.* **Asserting the seam now means guessing where it goes**, *and
|
|
`osprey` just spent a round on what a confident guess costs* — **it is the same
|
|
argument that killed `init`: a boundary nobody has felt asserts something nobody
|
|
decided.**
|
|
|
|
**Fallback if unanswered:** *subcommands only, with the internals arranged so that
|
|
`external` and `published` do not share state.* **If that stays true, the plugin
|
|
seam is discovered rather than designed.**
|
|
|
|
## Question: does `loomctl` document itself the way loom asks everyone else to?
|
|
|
|
**Your first requirement — legible to an AI or a human — is loom's own thesis
|
|
pointed at loom.** *So the test is available: **this repository has a
|
|
`.loom/published/`, and `loomctl published check` can be run against it.***
|
|
|
|
*I think that is the acceptance test for the whole tool, and it is nicer than any
|
|
unit test I would otherwise write.* **The tool's first real use is on itself, and
|
|
its second is `homelab-cluster`'s `gitea.md`** — *the document I needed and could
|
|
not read.*
|
|
|
|
**Fallback if unanswered:** *I write `.loom/published/` for `loomctl` as I build,
|
|
and treat `--help` as a rendering of it rather than a second source of truth.*
|
|
|
|
## Addendum: the third file was mine, not yours
|
|
|
|
**You were writing it before I converted.** *Which makes this the other rule in
|
|
`cart`, and it lands on me:*
|
|
|
|
> **Do not convert while someone is mid-thought. Ask whether they are ready. If
|
|
> you convert alone anyway, say so.**
|
|
|
|
**I did ask, and you said the plan was good, and you were mid-thought anyway.**
|
|
*So the check passed and was still wrong* — **approving a plan is not the same as
|
|
having nothing left to say**, and the question as `cart` phrases it invites the
|
|
first answer when it wants the second.
|
|
|
|
*Worth reporting upward: **"are you ready for me to convert?" and "is there
|
|
anything you are still writing?" are different questions**, and only the second one
|
|
would have caught this.*
|
|
|
|
**Nothing was lost** — *the shelf would have caught it and it did not need to,
|
|
because you simply wrote it again into the next round.* **Which is the mechanism
|
|
working.**
|
|
|
|
---
|
|
|
|
# Measured: git, and your instinct was right
|
|
|
|
**Taken: `published check`.** *`reachable` is gone as a word.*
|
|
|
|
## `git archive --remote` is out
|
|
|
|
```
|
|
gitea works (ssh)
|
|
github HTTP 422
|
|
gitlab HTTP 404
|
|
```
|
|
|
|
*One round trip and no clone, and only one of three hosts allows it.*
|
|
|
|
## Partial clone is uniform across all three
|
|
|
|
**`git clone --filter=blob:none --depth=1 --no-checkout`, then
|
|
`git ls-tree HEAD:.loom/published`:**
|
|
|
|
```
|
|
gitea filter ok .git=124KB
|
|
github filter ok .git=124KB
|
|
gitlab filter ok .git=144KB
|
|
```
|
|
|
|
**Same flags, same command, same output shape.** *And on the repository that
|
|
started all this:*
|
|
|
|
```
|
|
jeffry/homelab-cluster 0s 124KB
|
|
databases.md gitea.md private-access.md
|
|
public-access.md recovering-data.md storage.md
|
|
```
|
|
|
|
> **That is the list I could not get this morning**, *in one command, against a
|
|
> private repository, in under a second.*
|
|
|
|
## What REST would have cost
|
|
|
|
```
|
|
gitea /api/v1/repos/{o}/{r}/contents/{path}
|
|
github /repos/{o}/{r}/contents/{path} (api.github.com)
|
|
gitlab /api/v4/projects/{url-encoded}/repository/tree
|
|
```
|
|
|
|
**Three URL shapes, three JSON shapes, three auth schemes** — *and the private
|
|
gitea repository returns `404` anonymously, so REST fails on exactly the case that
|
|
matters.* **Your sense was right and it is now measured rather than felt.**
|
|
|
|
## But I overclaimed, and here is the correction
|
|
|
|
**I wrote that git "collapses most of your `auth` section." It does not.**
|
|
|
|
*`list` discovers names over git. **`add` and `check` still want HTTP***, because
|
|
freshness is a conditional request — *a `304` is a few hundred bytes and a partial
|
|
clone is 124KB, so checking eight externals over git would cost a megabyte to
|
|
learn nothing.*
|
|
|
|
**And raw HTTP against a private repository is `404` without a token.** *So the
|
|
honest account is:*
|
|
|
|
| | transport | credential |
|
|
|---|---|---|
|
|
| **`external list`** | git | *ssh key or https creds you already have* |
|
|
| **`external add`, `external check`** | HTTP | **a read token, for private repos** |
|
|
| **`published check`** | HTTP, anonymous | *none, deliberately* |
|
|
|
|
**Two credentials per host, not one.** *The argument for git is **consistency**,
|
|
not thrift* — **one mechanism against three hosts instead of three adapters** —
|
|
*and that argument stands on its own.* **The auth saving was me liking my answer.**
|
|
|
|
*Untested: whether a gitea read token actually works against raw HTTP on a private
|
|
repository.* **It is the specimen's assumption and nobody has run it.**
|
|
|
|
## The failure mode to build for
|
|
|
|
**`--filter` is a server capability.** *All three hosts here allow it; an older
|
|
self-hosted gitea or gitlab may not, and **git's fallback is silent** — it warns
|
|
`filtering not recognized by server` and downloads everything.*
|
|
|
|
**`--depth=1` bounds the damage to one commit**, *which is why both flags are
|
|
there and not just the filter.* **Detect the warning and say so**, rather than
|
|
letting `list` quietly become a full clone of somebody's monorepo.
|
|
|
|
---
|
|
|
|
# The token, and `published check` was wrong
|
|
|
|
**Done with the token — delete it whenever you like.** *It is in no file, no
|
|
commit and no config; it lived in a shell variable in a scratch directory.*
|
|
|
|
## Three things it settled
|
|
|
|
**The specimen's assumption holds.** *A read token against **raw** HTTP on a
|
|
private repository:* `404` *anonymous,* **`200` with `Authorization: token`** *(and
|
|
with `Bearer` — both forms work).* **Conditional requests work authenticated:
|
|
`If-None-Match` returned `304`.** *So `add` and `check` work on private
|
|
repositories, which is the whole of `homelab-cluster`.*
|
|
|
|
**The gitea coincidence holds on private repositories too** — *`ETag`
|
|
`530c5bef…` equals the blob hash.* **Which changes nothing**, *because we do not
|
|
use it.*
|
|
|
|
**And the specimen's sharpest claim is now measured rather than asserted:**
|
|
|
|
```
|
|
GET /api/v1/repos/jeffry/homelab-cluster
|
|
permissions: {admin: true, push: true, pull: true} ← with a READ token
|
|
```
|
|
|
|
> **A token cannot be asked what it may do.** *The API reports the **account's**
|
|
> rights.* **Scope is set once at creation and invisible afterwards, so the name
|
|
> is the only record** — *and that is now a thing we have seen rather than a thing
|
|
> we were told.*
|
|
|
|
## You are right, and it breaks my design rather than dents it
|
|
|
|
**I built `published check` on "can somebody anonymous fetch this", and
|
|
publication is not the same as being public.** *`homelab-cluster` publishes six
|
|
documents and is private on purpose, and that is not a defect — **it is what
|
|
`publication.md` actually says:***
|
|
|
|
> **What a repository has handed over for **others** to depend on.**
|
|
|
|
***Others*, not *everyone*.** *I read one word as the other and built a test on
|
|
it.*
|
|
|
|
## What the check has to be instead
|
|
|
|
**The question is not "is this public". It is "can the audience I published for
|
|
fetch it".** *And the audience is not a thing the tool can know.*
|
|
|
|
**So `published check` must never report success. It reports the identity it
|
|
used:**
|
|
|
|
```
|
|
gitea.md 200 as anonymous
|
|
gitea.md 200 as reader
|
|
gitea.md 200 as jeffry — OWNER, this proves nothing
|
|
```
|
|
|
|
**The third line is the one that earns the command.** *`/api/v1/user` gives the
|
|
login; the repository gives its owner; if they match, **the check is vacuous and
|
|
should say so instead of printing something green.***
|
|
|
|
> **That is `publication.md`'s own sentence, enforced:** *publishing is not an act
|
|
> you can complete alone.* **A tool that verifies your publication using your own
|
|
> credential has helped you complete it alone**, *and told you it was fine.*
|
|
|
|
**And it ties your `auth` section to something real:** *a kubeconfig-shaped config
|
|
is not only credentials, it is **named identities you can test as**.* **`--as
|
|
<context>` is the whole feature.**
|
|
|
|
## Affirm: no audience declaration?
|
|
|
|
**I do not want to add a field where a repository states who it published for.**
|
|
|
|
*It would let the tool compare intent to result, which sounds good* — **and it
|
|
asserts a category nobody has decided**, *which is the argument that killed
|
|
`init`.* **Report the identity, let the person judge the audience.**
|
|
|
|
**Fallback if unanswered:** *no declaration; `--as` defaults to anonymous, and the
|
|
owner-identity warning is always on.*
|
|
|
|
## You were right to make me try it, and it corrects me
|
|
|
|
**I wrote, one message ago, that the specimen's claim was "now measured rather
|
|
than asserted." I had measured the wrong half.**
|
|
|
|
```
|
|
POST /api/v1/repos/loom/loom-cli/contents/… 403
|
|
token does not have at least one of required scope(s),
|
|
required=[write:repository],
|
|
token scope=read:activitypub,read:admin,read:misc,read:notification,
|
|
read:organization,read:package,read:issue,read:repository,read:user
|
|
```
|
|
|
|
**The refusal names the scope.** *So "a token cannot be asked what it may do" is
|
|
too strong* — **and I confirmed it enthusiastically without testing the one case
|
|
that would have shown it wrong**, which is the failure mode this project keeps
|
|
finding in itself.
|
|
|
|
**What is actually true, and it is sharper:**
|
|
|
|
> **A token cannot be asked what it may do. It can only be told, by being
|
|
> refused.**
|
|
|
|
*Verified: `/api/v1/users/{u}/tokens` rejects token auth entirely (`401 auth
|
|
required` — it wants a password), no scope header comes back on success, and
|
|
`/api/v1/repos/…` reports `admin, push, pull: true` for a token that cannot
|
|
write.* **The only channel is the error body.**
|
|
|
|
**For us that changes the reason and not the rule.** *`loomctl` never writes, so it
|
|
will never provoke the one response that would tell it anything.* **The name
|
|
remains the only record we will ever see** — *but because we never ask, not
|
|
because nobody answers.*
|
|
|
|
**One concrete consequence:** *gitea's error bodies carry the useful part.*
|
|
**Surface them rather than mapping a `403` to a sentence of our own** — *we would
|
|
be throwing away the only scope information the host ever emits.*
|