Files
loom-cli/.loom/cart/current/claude-marmalade.md
T
jeffryandClaude Opus 5 76f5aef17a marmalade: reviewed — ssh is needed nowhere, and write access nowhere either
Every operation is HTTPS on 443. Measured that partial clone with the blob filter
works over gitea's smart HTTP, so list never needed ssh; I had built and measured
it over ssh out of habit.

The tool writes only to the working tree — a fetched document, a lock, a facet —
and never commits or pushes, so it needs no write credential anywhere, including
this repository. Same reason cart gives for an agent committing and not pushing:
the consequences of a push land on people it cannot experience. write:repository
should never appear on a token this tool holds.

The exception is published check, which needs an identity that is not the owner's.
That is not an ssh problem and not solvable by scoping — it is the command's point.
Anonymous or a reader credential, both HTTPS, and checked-as-owner-proves-nothing
when neither is held.

One untested assumption, and it is load-bearing: whether a read-scoped PAT
authenticates git over HTTPS and not only the API and raw files. Those are
different paths in gitea and only the second was ever measured. If it needs a
broader scope, that is the sole argument for keeping ssh for list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-07 14:29:13 -04:00

13 KiB

claude — cart marmalade

osprey converted, so this is a new round. Its name is meaningless and I picked it arbitrarily.

Rewritten to dissolve your annotations. The prompts below are answered rather than asked, and what is still open says so.


How the round opened

Your notes arrived as jeff-osprey.md, into a cart that had converted. Moved unedited to loom-marmalade.md, because cart allows two files and gives the test — what happens to this file when the person changes? jeff-… dies with you, so it is a person and not a stake, and loom renamed itself to loom last round saying that if you wrote here you would write into that file.

And the timing was mine, not yours. cart says do not convert while somebody is mid-thought; I asked, you approved a plan, and approving a plan is not the same as having nothing left to write.

You called that the design rather than the defect, and you are right. There is a deliberate buffer — better to make a mess occasionally, inside the cart, than to carry rules strict enough to prevent it. The shelf exists for this and did not even need to be used, because you simply wrote it again into the next round.

So it is recorded as the mechanism working, not as something to fix.


Settled this round

published check, and why it cannot report a verdict

reachable is gone as a word. The command is loomctl published check, so that check means one thing on both sides: ask somebody else whether what we believe is still true.

And the test is not "is this public". I built it that way and you broke it: homelab-cluster publishes six documents and is private on purpose. publication.md says what a repository has handed over for others to depend onothers, not everyone, and I read one word as the other.

So the command reports the identity it used, never a verdict:

gitea.md   200  as anonymous
gitea.md   200  as reader
gitea.md   200  as jeffry — OWNER, this proves nothing

The third line is what earns the command. publication.md says publishing is not an act you can complete aloneand a tool that verifies your publication with your own credential has helped you complete it alone, and told you it was fine.

Which makes your auth section load-bearing: a kubeconfig-shaped config is not only credentials, it is named identities you can test as. --as <context> is the whole feature.

No audience declaration

Confirmed: no field where a repository states who it published for. It would let the tool compare intent to result, and it asserts a category nobody has decidedthe argument that killed init.

--as defaults to anonymous and the owner warning is always on.

Your future version is better than a declaration and does not need one: project it from the other side — read everybody's externals/ and derive who is actually depending on you. Nothing now; it is registry's shape, and registry is deliberately not built.

external list talks to git

Measured across three hosts. git archive --remote is out — gitea allows it, GitHub 422s, GitLab 404s. Partial clone is uniform:

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

jeffry/homelab-cluster   0s   124KB   over ssh, private
  databases.md  gitea.md  private-access.md
  public-access.md  recovering-data.md  storage.md

REST would have been three dialects, not one with variations:

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

The argument for git is consistency, not thrift. One mechanism against three hosts instead of three adaptersand REST 404s on the private repository, which is the case that matters.

The failure mode to build for: --filter is a server capability and git's fallback is silent, warning filtering not recognized by server and downloading everything. --depth=1 bounds the damage, which is why both flags are there. Detect the warning and say so, rather than letting list quietly become a full clone of somebody's monorepo.

Transports, honestly

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 not the owner's — that is the point

Two credentials per host, not one. I claimed earlier that git collapsed most of the auth story; it does not. add and check want HTTP because freshness is a conditional requesta 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.

Subcommands now; plugins when somebody reports a gap

loomctl external …, loomctl published …, and loomctl beads … when it exists.

No plugin boundary yet, and your framing is better than my reasoning was: the seam gets built when it is a reported gap, not a designed one. kubectl and gh both grew plugins after somebody outside wanted inand a gap is exactly how loom says that arrives, with a name attached and a workaround recorded beside it.

--help is the documentation surface; .loom/published/ is later

You are right about what actually happens: hand somebody an unfamiliar CLI and they run --help. That is the discovery surface, so it is the one that has to be goodfor a person and for an agent, which mostly means the same thing: 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 and this is not. When somebody depends on this tool's behaviour rather than reading its help, that is when it has a contract worth publishingand published check gets to be run against its own repository, which is a nicer acceptance test than anything I would write.


Measured, so nobody re-derives it

A read token, issued for one session and since deleted.

It authenticates raw HTTP on a private repository. 404 anonymous, 200 with Authorization: token or Bearer, and If-None-Match returns 304 authenticated. So add and check work on private repositoriesthe assumption the specimen made and nobody had run.

The gitea ETag/blob coincidence holds on private repositories too. Which changes nothing, because we do not use it.

And the specimen's sharpest claim needed correcting, by you making me try the write:

POST …/contents/…    403
  required=[write:repository]
  token scope=read:activitypub,…,read:repository,read:user

/api/v1/repos/{o}/{r} reports admin, push, pull: true for a token that cannot write — those are the account's rights. But the refusal names the scope.

A token cannot be asked what it may do. It can only be told, by being refused.

No other channel exists: /api/v1/users/{u}/tokens rejects token auth outright, and no scope header comes back on success. For us this changes the reason and not the ruleloomctl never writes, so it never provokes the one response that would tell it anything. Surface gitea's error bodies rather than replacing them, since they carry the only scope information the host emits.


Still open: where the credential lives

The token is deleted and we said we would decide this soon. It is the one thing this round has not answered.

What osprey fixed and still holds: it lives in ~, never in a repository, and it is named for its scope because the name is the only record.

What is new, and what makes it harder: the config is no longer only a secret. It is how you talk to a host at allAPI shape, ssh on a strange port, which transport check should use, and now named identities to test as. Some of that is not secret and several people would want the same values.

The obvious answer is to split it: a host list that can be committed, and a credential file that cannot. I have not thought it through and I am naming it rather than deciding it.

Fallback if unanswered: one file in ~, kubeconfig-shaped, holding both — and an entry recording that the split is expected and was not done yet. Nothing in the repository, and nothing that reads from the repository, so that the mistake we would most regret is the one that cannot happen by accident.


Corrections I made this round

Kept together, because there are three and the pattern is the point.

I claimed git collapsed the auth story. It does not — I liked my answer.

I confirmed the token claim enthusiastically without testing the case that would show it wrong. You made me try the write and the refusal named the scopes.

I built published check on anonymous fetchability. Publication has an audience and the audience is not always everyone.

All three are the same shape: a result that agreed with me, and no test of the case that would not have. It is also exactly what osprey found in the ETag storya verified fact is much louder than a ruleand I have now done it three times in a day while writing that sentence down.

Not an apology. A thing to point a test at, and the reason the log carries a belief rather than a conclusion.


Reviewed: what needs ssh, and what needs write

Short answer: ssh, nothing. Write access, nowhere — including here.

Every operation, and what it actually needs

what it does transport credential
external list partial clone, ls-tree the published dir git over HTTPS read PAT
external add GET the raw file, record URL + ETag HTTPS read PAT
external check conditional GET, If-None-Match HTTPS read PAT
published check GET our own published file HTTPS not ours
everything it writes files in the working tree none none

Measured just now: git clone --filter=blob:none --depth=1 --no-checkout over https:// on giteafilter accepted, 124KB, ls-tree lists the published directory. I had built and measured list over ssh out of habit, and it never needed it.

The tool never writes over the network, so it needs no write credential

Including in this repository. loomctl puts files in the working tree — a fetched document, a lock, a facet — and stops. Committing and pushing are yours, under your own credentials.

That is the same reason cart gives for an agent committing and not pushing: the consequences of a push land on people it cannot experience. A tool that can push is a tool that can publish, and publishing is supposed to be an act somebody performs.

So write:repository should never appear on any token this tool is given, and a token that has it is a token we would rather not hold.

The one thing a single PAT cannot do

published check needs an identity that is not the owner's, and yours is yours. That is not an ssh problem and it is not solvable by scoping — it is the command's entire point.

Two workable identities, both HTTPS:

  • anonymousthe right answer when the audience is everyone
  • a second credential belonging to the actual audiencea reader account, or a token from somebody in the group you published for

And if you hold neither, the honest output is "checked as owner, proves nothing", which is still worth printing.

One thing untested, and it is the load-bearing one

Whether a gitea PAT scoped read:repository authenticates git over HTTPS, not just the API and raw files. They are different paths in gitea, and the deleted token was only ever measured against the second. Measured: an unauthenticated HTTPS clone of the private repository returns Failed to authenticate user, so something is required; nobody has confirmed that a read PAT is enough.

First thing to run against the next token. If it turns out git-over-HTTPS needs a broader scope than raw HTTP, that is an argument for keeping ssh for list after alland it would be the only argument.

An aside that is too neat not to record

:2222 cost me three tool calls and a guess, and the tool does not need it. Every transport here is HTTPS on 443. The fact that bit me is a fact for people cloning repositories, not for loomctlwhich is its own small lesson about who a published document is for.

Implementation note: keep the token out of the URL

Do not build https://<token>@host/…. git writes the remote into .git/config, and it turns up in logs and in git remote -v afterwards.

Pass it per-invocation instead:

git -c http.extraHeader="Authorization: token <t>" clone --filter=blob:none …

Nothing persisted, nothing in the repository. Which is the same rule as the credential file: the mistake we would most regret is the one that happens by accident.