Files
loom-cli/.loom/cart/current/claude-marmalade.md
T
jeffryandClaude Opus 5 24610ba353 marmalade: publication is not publicness, and a token tells you by refusing
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
2026-09-07 14:13:44 -04:00

15 KiB

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 stakeand 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 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 doand 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 APIgitea /api/v1/repos/{o}/{r}/contents/.loom/published, GitHub /repos/{o}/{r}/contents/…, one adapter per host
  • git itselfa treeless or shallow fetch, then ls-tree the published directory; the same protocol everywhere, no adapters, no API tokens
  • a published indexthe 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 hereso 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 dogitea's /api/v1/repos/… reports the account's rights, not the token'sso 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 costsit 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.mdthe 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 wrongapproving 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 lostthe 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 schemesand 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 thriftone mechanism against three hosts instead of three adaptersand 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 tooETag 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 recordand 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 goodand 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 strongand 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 seebut 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 ownwe would be throwing away the only scope information the host ever emits.