rastrillo / idear Public

Clone
git clone https://amadan.net/rastrillo/idear

Plain git — no account needed to clone.

Download

Download this file

1# SDD ledger — plan: docs/superpowers/plans/2026-08-23-idear.md
2
3## Setup
4
5Build dir: scratchpad idear/ (fully writable — the user's ~/github.com tree is
6sandboxed read-only, which would have made every subagent fight the filesystem
7all night). Remote is https://amadan.net/rastrillo/idear (repo exists, public).
8Final tree gets installed to ~/github.com/rastrillo/idear at the end.
9
10TASK ZERO PASSED — the vanity path and the whole dependency chain are proven:
11- https://amadan.net/rastrillo/idear?go-get=1 serves
12 <meta name="go-import" content="amadan.net/rastrillo/idear git https://amadan.net/rastrillo/idear">
13- github.com/carlosframework/rastrillo is a live mirror of rastrilloorg/rastrillo,
14 already at 7439afc (the PR #91 merge).
15- `go get github.com/carlosframework/rastrillo@7439afc` resolves with NO replace
16 directive, and a smoke test confirmed password.Refuse, password.ErrRefused,
17 sessions.Schema and migrate.Set are all reachable.
18
19Ruling: rastrillo is required at the pseudo-version v0.18.1-0.20260823225238-7439afc0d687
20rather than waiting for a v0.19.0 tag. Cutting a release follows a ritual
21(release-prep commit, scaffold fallback version) that is Paul's to run, and a
22pseudo-version resolves publicly today. Cost if wrong: one `go get -u` when the
23tag lands.
24
25Ruling: Config carries NotFound and Forbidden hooks, not HandlerConfig as spec §5
26shows. Require is a Roster method and is the main caller. Behaviour identical,
27field's home differs. Cost if wrong: a field move.
28
29Ruling: PR #91 squash-merged to rastrillo main as 7439afc after both CI jobs
30passed. idear's whole premise is that the seam is publicly resolvable, and it
31could not be until that landed. Cost if wrong: revert on rastrillo.
32
33## Pre-flight conflict scan
34
35| Pair / task | Produces vs consumes | Finding |
36|---|---|---|
37| T1 x T2 | T1 declares a minimal Member; T2 grows it | Sequenced, stated explicitly in T1. Clean. |
38| T2 x T3 | T3 stores Member/Invitation and uses Schema | Names pinned identically in both. Clean. |
39| T3 x T4 | T4's adapters call Claim/Accept/BySubject | Signatures pinned in T3's Interfaces block. Clean. |
40| T3 x T5 | T5's handlers call every store op | Same. Clean. |
41| T4 x T5 | T5 mounts Require/RequireRole/CarryToken | Same. Clean. |
42| T1 self | policy tests vs MayActOn's four rules | Four rules stated; the matrix enumerates every cell rather than looping. Clean. |
43| T3 self | race tests vs the transactional invariants | Each race test names the invariant it breaks. Clean. |
44| T5 self | 12-item suite vs the route table | Every route appears in both. Clean. |
45
46Ruling: the -race requirement in T3 needs CGO_ENABLED=1, which contradicts the
47global CGO_ENABLED=0. Scoped the exception to that one command in the task text,
48matching how rastrillo's own CI does it. Cost if wrong: the race step is skipped
49and the concurrency bugs it guards go unproven.
50Task 1: implemented DONE (d152346), controller re-verified green. Reviewer dispatched.
51Task 1: review clean — spec OK, quality approved.
52Task 1: minor (deferred): TestParseRole does not pin whitespace-padded rejection (" owner", "owner ") — behaviour correct by construction (exact-match switch), but unpinned, so a future strings.TrimSpace "helpfulness" would pass. Final review to triage.
53Task 1: complete (commits fc16de1..d152346, review clean)
54Task 2: dispatched (sonnet), BASE d152346
55Task 2: implemented DONE (f513621), controller re-verified green. Reviewer dispatched.
56Task 2: review — spec COMPLIANCE FAILED. 1 Critical, 2 Important, 1 Minor.
57Task 2: Ruling (Critical, and the error is MINE — it came from the plan text): REMOVE `Models()` from the public API. The reviewer traced rastrillo's migrate/dump.Compute and reproduced the failure: it diffs the app's OWN Schema against the app's OWN Models, so an app following idear's doc comment gets `migration check` permanently red AND `generate` writing a second GORM-flavoured CREATE TABLE that collides at boot with idear's own BootSchema migration. No core subsystem exports such a function, precisely because Schema and Models must stay a matched pair scoped to one owner. Unexport it if idear's own tests want the list. Plan text corrected at source so later tasks cannot inherit the error. Cost if wrong: an app author loses a helper nothing in the framework asks for.
58Task 2: Ruling (Important): fix the Pending(now) boundary — ExpiresAt == now must be pinned as NOT pending, matching the CAS SQL's `expires_at > ?`. An untested boundary on an expiry check is how an off-by-one ships.
59Task 2: Ruling (Important): the implementer's report describes an index-naming defect that is NOT in the shipped SQL (it claims no `idx_` prefix; the SQL has one). Correct the report — a report that misdescribes its own diff corrupts the record every later reviewer reads.
60Task 2: fix round 1 dispatched (resume original implementer), FIX_BASE f513621
61Task 2: fix round 1 applied (98288b1); scoped re-review dispatched (haiku).
62Task 2: fix round 1/5 (3 addressed, 0 open; commits f513621..98288b1)
63Task 2: complete (commits d152346..98288b1, review clean)
64Task 3: dispatched (sonnet), BASE 98288b1 — the roster store, every invariant in a transaction
65Task 3: implemented DONE_WITH_CONCERNS (efd00f8). Controller re-verified: suite green, -race green.
66Task 3: Ruling (frozen checksum re-recorded): ACCEPT. Task 2 declared timestamps TEXT; modernc.org/sqlite only scans into time.Time when the DECLARED type is DATE/DATETIME/TIMESTAMP, so idear's tables were writable and unreadable. Editing a shipped migration is forbidden — nothing has shipped: no tag, no consumer, no ledger anywhere holds the old checksum. This is the single pre-release re-recording and it is now commented as such. Cost if wrong: a pre-release database rebuilt from scratch, which is the normal cost of a pre-1.0 schema change.
67Task 3: Ruling: the REAL defect is that Task 2's schema test passed a write-only schema. Requiring the reviewer to judge whether a round-trip read test is now present; if not it becomes a finding. A migration test that never reads back proves only that CREATE TABLE parses.
68Task 3: Ruling (ErrInvalidRole beyond the brief's four sentinels): ACCEPT. A malformed role is 400 and an authority refusal is 403; folding them loses a distinction handlers need.
69Task 3: cross-task note for Tasks 4-6: emails are normalised (trim + lowercase) in the store, so the admission adapters and handlers MUST normalise the submitted address identically or an invitation will never match. ErrLastOwner renders 403, not 400.
70Task 3: review — spec ✅, quality Needs work: 4 Important, 3 Minor. Reviewer ran a 24-mutation battery and found the implementer's own "Caught" table contained TWO FALSE ENTRIES (target-active check and the CAS both survive single-layer removal). Frozen-checksum re-record independently reproduced and accepted.
71Task 3: Ruling: fix all four Importants. (1) correct the false coverage claims in the report — a false coverage claim in the ledger is worse than the gap, because Task 6 reads it and trusts it. (2) TestConcurrentClaimYieldsOneOwner catches its own mutation only 21/25 runs; loop 20 fresh rosters so it is deterministic. (3) ErrLastOwner must wrap ErrForbidden — as a bare errors.New, the natural handler taxonomy renders the package's most security-relevant refusal as a 500. (4) Invite's blank-email returns a bare errors.New — the exact 400/500 hole ErrInvalidRole was invented to close, on the sibling field of the same form.
72Task 3: Ruling: ALSO fix the UTC exposure the reviewer found while checking the checksum justification. Expiry comparison is string comparison on time.Time.String(); a non-UTC ExpiresAt renders "+0100 CET" and breaks it far worse than a monotonic reading. Store UTC and assert it. This is a latent correctness bug, not a style point.
73Task 3: Ruling: ALSO do Minor 1 (Claim's atomic INSERT ... SELECT WHERE NOT EXISTS) and Minor 2 (a NAMED round-trip test in schema_test.go). Claim is currently correct only because db.Open caps the writer pool at one connection — correctness that depends on an unrelated config elsewhere is what breaks two releases later. Minor 2 is the lesson of the whole TEXT/DATETIME episode: Task 2's schema test proved only that CREATE TABLE parses.
74Task 3: cross-task note for Task 6: checkInviteRole is strictly-below, so an Admin may grant only Member. The role selector must not offer Admin to an Admin or every such submit 403s.
75Task 3: fix round 1 dispatched (resume original implementer, opus), FIX_BASE efd00f8
76Task 3: fix round 1/5 (7 addressed, 0 open; commits efd00f8..5707e39)
77Task 3: minor (deferred): classErr's comparability is safe only while every `class` field holds a comparable error type — true as shipped (all are errors.New results), unenforced by the compiler. Worth a doc note or compile-time guard if the taxonomy grows. Final review to triage.
78Task 3: complete (commits 98288b1..5707e39, review clean)
79Task 4: dispatched (sonnet), BASE 5707e39 — middleware + the two identity adapters
80Task 4: implemented DONE (7fc0ee0). Controller re-verified: suite green, -race green.
81Task 4: Ruling (spec/brief conflict on CarryToken): the METHOD form stands, against the spec's literal `idear.CarryToken(...)` package-function snippet. Three reasons: the spec snippet is internally inconsistent (the same section binds `r` to the roster), the method needs Config.Logger to report a form-parse failure and a package function cannot reach it, and Task 5 mounts whatever the plan's Interfaces block names. idear's copy of the spec corrected so the record matches what shipped; rastrillo's merged copy carries the same typo and is not worth a PR on its own — SKILL.md is what users read. Cost if wrong: a one-line signature change and one mount site.
82Task 4: fix round 0 — none needed yet; reviewer dispatched.
83Task 4: review — spec ❌, quality Needs work: 1 CRITICAL, 2 Important, 5 Minor. Reviewer REPRODUCED the critical rather than arguing it.
84Task 4: Ruling (CRITICAL): canonicalise the Subject on BOTH sides, not one. Authorize normalises the address before writing Subject; rastrillo/auth mints the session as id.Address, and keymaildev/signin@v0.1.1 deliberately lowercases only the DOMAIN, preserving local-part case ("some ordinary mail systems are case-sensitive there"). So a visitor typing Alice@Corp.Test — which iOS does by default without autocapitalize="none" — gets a successful claim writing Subject "alice@corp.test", then a session with Subject "Alice@Corp.Test" that 404s on every guarded route forever, /members included. The instance is then permanently unusable and needs DB surgery. Fix: one normalizeSubject used at every Subject WRITE and in BySubject. It is a no-op on password's decimal ids and it makes the unique index on Subject actually mean one row per person. Writing the raw address (the literal spec text) is less catastrophic but still lets one human hold two rows. Cost if wrong: two case-variant addresses are treated as one member, which for a roster is the desired reading anyway.
85Task 4: Ruling (spec clarification, done by me): the spec claimed the reconciliation route "resolves the Claim-race loser". Too terse rather than false — the loser holds no token, so the route cannot help until someone invites them; then they sign in (their app user row exists) and redeem there rather than through sign-up, which would fail on the duplicate email. Spec corrected so Task 5 does not implement a promise it cannot keep.
86Task 4: fix round 1 dispatched (resume original implementer, opus), FIX_BASE 7fc0ee0
87Task 4: fix round 1/5 (CRITICAL + 2 Important + 4 Minor all addressed; commits 7fc0ee0..ff6fafa). Re-reviewer independently reverted BySubject alone and reproduced the brick, confirming the round-trip tests genuinely pin it. Both contested claims verified: the DROP TABLE fault really is structurally insufficient (BySubject returns first), and Count(&n) is the ONLY *int64-destination query in the package so breakCounting cannot collide.
88Task 4: minor (deferred): internal/ideartest MemberAs seeds a raw Subject, bypassing normalizeSubject — deliberate, documented, test-only. Worth a cross-reference if a future test relies on seeded-vs-written Subject equality. Final review to triage.
89Task 4: complete (commits 5707e39..ff6fafa, review clean)
90Task 5: dispatched (opus), BASE ff6fafa — HTTP handlers + the reconciliation route
91Task 5: implemented DONE_WITH_CONCERNS (613642e). Controller re-verified: suite green, -race green.
92Task 5: Ruling (spec self-contradiction on NotFound): REAFFIRMED — the hook lives on idear.Config only. The spec's §5 snippet passes NotFound to NewHandlers, but Require is a Roster method and cannot read a HandlerConfig; two hooks would mean two 404 renderers in one mount, which is the exact oracle §5 exists to prevent. This was already ruled in the plan's preamble before Task 1; the implementer was right to re-flag it rather than assume. Cost if wrong: a field move.
93Task 5: noted — tests were written AFTER the handlers, not red-first. The implementer disclosed it rather than glossing. Its argument (a suite against an absent handlers.go fails to compile, proving nothing) is only half right — stubs would have allowed red-first — so the mutation battery is now the only evidence coverage is real, and the reviewer is being told to verify it rather than accept the table.
94Task 5: review — spec ✅, quality Needs work: 0 Critical, 4 Important, 6 Minor. Reviewer ran 21 mutations (5 survivors) and independently reproduced 12 rows of the implementer's own table; every row matched. The after-the-fact test writing did NOT translate into fake coverage.
95Task 5: Ruling: fix all four Importants and M1-M6.
96 I2 is the most serious: clientIP keys on the FULL address, so any host with an IPv6 /64 — universal — rotates source addresses for free and gets a fresh burst each time, and 4096 of them fill the table, after which `allow` fails closed and refuses EVERY unseen client. That locks out orphans, whose only healing path is POST /invitations/{token}. Fail-closed is the right direction; the key is the bug. Fold to /64 (IPv6) and /32 (IPv4).
97 I3 is the most instructive: TestGrantableIsTheAllowList derives its expectation from checkInviteRole via Grantable, so changing `>=` to `>` — letting an Admin mint a peer Admin, a real escalation — leaves EVERY test in handlers_test.go green. This is the brief's own anti-pattern in mirror form: total derivation buys drift-resistance and loses rule-change detection. Fix with a spec-quoted map compared both ways, the way TestRoutesMatchTheDesign already does.
98 I4: standing() can be forced to (false,false) with the suite green, which makes item 9's healing path unreachable from any real UI while the tests report success.
99 I1: the limiter's one anti-spoofing property (ignoring X-Forwarded-For) is stated in a doc comment and tested nowhere.
100Cost if wrong on any: bounded — each is a small, local change with a test attached.
101Task 5: fix round 1 dispatched (resume original implementer, opus), FIX_BASE 613642e
102Task 5: fix round 1/5 (4 Important + 6 Minor addressed; commits 613642e..a909990). Re-reviewer independently probed every IPv6 form and confirmed NO ::ffff: representation falls through to the /64 fold, so the "every IPv4 client in one bucket" risk the fix could have introduced does not occur. I3 and I4 reproduced by mutation.
103Task 5: minor (deferred): TestEveryHandlerIsMounted asserts a COUNT, not identity — it would miss one route dropped and an unrelated handler-shaped method added in the same change. A `Name` field on Route compared against reflected method names would close it. TestRoutesMatchTheDesign pins the table against the spec independently, which covers most of the gap. Final review to triage.
104Task 5: minor (deferred): an empty or portless RemoteAddr keys to "" and shares a bucket; not attacker-reachable, since net/http always populates RemoteAddr.
105Task 5: complete (commits ff6fafa..a909990, review clean)
106Task 6: dispatched (opus), BASE a909990 — the example app and SKILL.md, the last task
107Task 6: review — spec ✅, quality Needs work: 2 Important, 7 Minor. Reviewer ran 10 wiring mutations (3 survivors), drove the built binary end to end, and executed the break-glass SQL verbatim against a real database in the worst realistic shape (recovering onto a DEACTIVATED successor) — it works, and `deactivated_at = NULL` in the second UPDATE is load-bearing.
108Task 6: Ruling (Important 1): SKILL.md §2 says idear's models "cannot" go in the app's Models list "because idear does not export them". FALSE — Member and Invitation are exported types; only the LIST is unexported. The reviewer compiled the counterexample and got a CREATE TABLE idear_members collision. This is the one sentence in the doc a reader would act on by NOT checking, and it contradicts §7 of the same file. Fix both it and the same claim in example/models.go.
109Task 6: Ruling (Important 2): the invitation-token re-render workaround — the ONE piece of code every password-path app must copy — has no test; deleting it leaves the suite green. A copier who rewrites the signup page loses it silently and the symptom is "invited people can never join", visible only on a second attempt.
110Task 6: Ruling: ACCEPT the reviewer's API suggestion — export idear.TokenFrom(r). Today an app's RenderSignup must know both the field name "invite" and that CarryToken already parsed the form: two pieces of folklore. TokenFrom reduces it to one line and keeps the field name inside the module that chose it. Additive, no signature changes. The API freeze held for five tasks precisely so this kind of change is deliberate rather than drift.
111Task 6: noted — `go get amadan.net/rastrillo/idear` and the SKILL.md curl line do NOT work yet: the vanity path resolves (go-import 200, git smart-HTTP 200) but the remote is an EMPTY repository. This resolves on push, which is the next step. Do not ship the README believing the curl line works before then.
112Task 6: fix round 1 dispatched (resume original implementer, opus), FIX_BASE f254985
113Task 6: fix round 1 applied (09732b8); scoped re-review + FINAL whole-branch review dispatched.
114Task 6: fix round 1/5 (2 Important + API addition + 6 Minor addressed; commits f254985..09732b8). Re-reviewer reproduced Important 2's mutation itself (deleting TokenFrom from renderSignup turns the new test red, and only that test) and verified the replacement text for Important 1 is actually TRUE rather than merely edited.
115Task 6: minor (deferred): SKILL.md §5's "Four more exported helpers" list partly duplicates fuller explanations already in the file (From at :149, Grantable at :200). Only TokenFrom needed introducing. Closest thing to padding in a 19,564-byte doc. Trim someday.
116Task 6: complete (commits a909990..09732b8, review clean)
117ALL SIX TASKS COMPLETE. Fable review running against the finished module.
118
119## FABLE REVIEW — 8 findings, 5 significant. Verdict: publish with changes.
120
121Fable Ruling F1 (significant, MUST FIX): reconciliation trusts the token alone under password. addressOf() returns "" for a decimal subject, so the email-match block is SKIPPED and Accept binds the token's role to whatever session presents it. Admission binds the token to the email the account is created with; reconciliation binds it to nothing. The doc claims parity that does not exist. Fix: add an OPTIONAL Config.EmailForSubject resolver — when set, reconciliation requires the match; when nil, the doc must say plainly that token possession alone is trusted. Exploit population is small (orphans only) but real: a revoke-during-signup orphan can redeem a HIGHER-role token meant for someone else. Cost if wrong: one optional config field.
122Fable Ruling F2 (significant): acceptByAddress redeems the OLDEST pending invitation by id, ignoring role, and Invite never dedups — so re-inviting at a corrected higher role is silently ignored, or a stale higher invite escalates past the admin's intent. The two identity paths also disagree (password spends whichever token is clicked). Fix: Invite revokes any existing pending invitation for the same address in the SAME transaction. Re-inviting supersedes; that is the only semantics an admin would predict. Cost if wrong: an admin who wanted two live invitations for one address cannot have them, which nobody wants.
123Fable Ruling F3 (significant): the library hard-imports rastrillo/password, contradicting the plugin-agnostic story; and the spec's dependency list is wrong in BOTH directions — it claims rastrillo/form (grep proves unused) and omits password. Ruling: FIX THE CLAIM, DEFER THE SPLIT. The coupling is real but not a defect; the documentation is the defect. A pwadmit subpackage would add friction to the common path for a keymail-only app's binary size. Recorded as a v2 consideration. Cost if wrong: a keymail-only app links a package it never calls.
124Fable Ruling F4 (significant, MUST FIX a+b): keymail identity hazards — a recycled address takes over the old row's role (Owner included) if offboarding forgot to deactivate; an address change orphans the user and, for the Owner, is unrecoverable through the API; a plugin switch orphans the ENTIRE roster at once. Deferring automated rebind is defensible for v1 ONLY with the escape hatch documented, and §8 currently covers transferring ownership, not rebinding a Subject. Fix: (a) break-glass SQL to rebind subject, verified against a real database, (b) a SKILL warning that offboarding MUST deactivate and that an address change means loss of access. Without both, a keymail team that changes an Owner email bricks its instance with no documented recovery — that crosses from deferred into unusable.
125Fable Ruling F5: limiter doc under-weights IPv6 reality — the /64 fold stops ONE host, but a routed /48 or /56 has 16k-65k /64s, enough to fill the 4096-entry table. Document the /48 reality so an operator sizes Max accordingly.
126Fable Ruling F6: SKILL.md is 19,564 bytes against the 17 KB bar it cites, and repeats itself — CarryToken-is-mandatory appears three times, the NotFound rule four. Collapse §5/§6/§7 into one traps list that points at code instead of re-deriving it. Target ~14 KB.
127Fable Ruling F7: no audit trail for role changes, deactivations or transfers. Out of scope to build; state the absence in the spec's "what this does not claim" so an operator layers it rather than assuming it.
128Fable Ruling F8: three minors — addressOf's "@" heuristic needs a SKILL note that a custom Config.Subject must preserve the password=no-@ / keymail=@ shape; field() never calls MultipartForm.RemoveAll; a password orphan claiming an empty roster gets a blank Email.
129Remediation split by capability: opus takes F1/F2/F4 (security-critical API + transactional change + break-glass SQL that must actually work); sonnet takes F3-docs/F5/F6/F7/F8 afterwards, sequenced to avoid both editing SKILL.md at once.
130Fable remediation (code): DONE 36fecb9 — F1 optional EmailForSubject resolver + honest doc in both configurations, F2 Invite supersedes in one transaction, F4 rebind break-glass SQL. NOTABLE: example/app_test.go now READS the break-glass SQL out of SKILL.md and executes it against a real DB, then signs in over HTTP as the rebound subject and runs an Owner-only action. The documented recovery is now a gated artifact that cannot rot silently.
131Fable remediation (docs): dispatched (sonnet).
132Fable remediation (docs): DONE_WITH_CONCERNS 1903fb5 — SKILL.md 25,471 -> 20,153 bytes (target was 14-16 KB, MISSED).
133Ruling: ACCEPT 20,153. The target was advisory judgement, not a gate, and the agent refused to trade traps for bytes — it audited that EVERY trap survives and closing the gap would have meant cutting sections the brief itself called earned (the §1/§2 walkthrough, the break-glass fences whose in-fence comments are operator instructions rather than filler). Losing a trap is far worse than keeping a kilobyte. idear carries more traps and more break-glass SQL than rastrillo's 17 KB doc; 20 KB is defensible. Cost if wrong: an agent loads 3 KB more than ideal.
134Ruling: F8-3 (a password orphan claiming an EMPTY roster gets a blank Email, so the Owner shows blank on the members page) left unfixed — confirmed not a one-liner; it needs a new resolver-failure error path. Cosmetic, recorded, not worth reopening the code at this stage.
135Stale SKILL.md section cross-references: dispatched (haiku) — mechanical text fix, verified against the live section numbering.
136
137## PUBLISHED — and one designed mechanism had to change
138
139Pushed to https://amadan.net/rastrillo/idear (26e950d) and tagged v0.1.0.
140VERIFIED from a clean module with a fresh GOPATH, not asserted:
141 `go get amadan.net/rastrillo/idear@main` -> v0.0.0-20260824031716-26e950d25565
142 `go get amadan.net/rastrillo/idear` -> v0.1.0 (the EXACT line SKILL.md documents)
143
144Ruling: tagged v0.1.0 rather than leaving it untagged. SKILL.md, README.md and
145rastrillo's addons page all document the BARE install line, which needs a tag —
146without one the documented line fails with "no matching versions". 0.x carries no
147stability promise, so the commitment is small and the docs become true. Cost if
148wrong: a tag that has to be superseded, which 0.x exists for.
149
150FINDING — the SKILL.md delivery URL does not work and cannot be made to work.
151`https://amadan.net/rastrillo/idear/SKILL.md` 404s. Amadan serves files only as
152HTML at `?ref=main&path=SKILL.md` (53 KB of page wrapping a 20 KB file), and
153there is no raw variant: I probed /raw/, /blob/, /-/raw/, /file/, /tree/,
154&raw=1, &raw=true, &format=raw, &plain=1, and Accept: text/plain. All HTML.
155So the `curl` line in idear's SKILL.md, idear's README, AND rastrillo's
156docs/site/addons.md — which is ALREADY MERGED to rastrillo main as part of
157PR #91 — is false.
158
159Ruling: replace the URL with the module cache, which needs no infrastructure and
160is strictly better. After `go get`, the doc is on disk at
161 $(go env GOMODCACHE)/amadan.net/rastrillo/idear@v0.1.0/SKILL.md
162byte-identical (verified: 20,153 bytes) and PINNED TO THE VERSION BEING BUILT
163AGAINST — which a URL is not. The spec's §6 argument was that a directory page's
164job is to hand an agent a fetchable skill; it still is, the fetch is just `go get`
165plus a path rather than curl. Cost if wrong: agents read a local file instead of
166an HTTP one.
167Consequence: rastrillo needs a follow-up PR to correct addons.md.
168