feat: a grid can be keyed on an operating system (ADR 0039) - #80
Merged
Conversation
ADR 0039 D-a adds one network type, `os-community`, whose two copies live in grid-src (`network_runtime.py` and `private_server/grid_auth.py`) with no import path to either. This adds the ADR and the cross-repo pin. `tests/test_os_grid_type_lockstep.py` parses both grid-src modules with `ast` and checks four things a rename or a dropped set member would otherwise break in silence: the literal in each module, its presence in `VALID_NETWORK_TYPES`, `VALID_NETWORK_TYPE_INPUTS` and `PUBLIC_NETWORK_TYPES`, and its presence in `_requires_allowlist`'s open-type tuple. The last is the one no successful create catches — the grid provisions, starts, appears in `grid ls`, and then 403s every member, with no allowlist row anyone can add because D-e persists nothing about a person's OS. Every case carries a positive control (`private-domain` must be found by the same helper), so a resolver that has quietly stopped reading anything fails as a harness fault rather than reading as "the seam is fine". Six mutants applied to grid-src, six killed. `tests/grid_src_repo.py` extracts the sibling resolver `test_starter_engine_ lockstep.py` had inline, so the two suites cannot drift into two ideas of where grid-src is — a resolver that drifts does not fail, it skips. It also now validates `GRID_SRC_REPO` and RAISES on a bad one: `tests/e2e_cross_repo/ _harness.py` defaults that same variable to a different worktree, so exporting it for the E2E would otherwise switch off every pin in both suites in silence. Per this repository's rule for cross-repo assertions, the new suite skips unless grid-src sits beside this worktree — it skips in CI, and a green CI proves nothing about it. Full suite: 3225 passed, 9 skipped, 0 failed.
Adds the public CLI's half of ADR 0039 (issue 02). `shared/system/os_grid .os_token()` is the only place in this CLI that decides what OS this machine runs: `macos`, `windows`, `linux`, or nothing. `remote/control_plane .fetch_tokens` sends it as a new `os=` query parameter beside `device_id` — the only channel there is, since the device-login start and poll calls carry nothing about the machine and the browser approving a sign-in may be a different device entirely. The taxonomy is CLOSED, and that is what makes issue 03's auto-provisioning safe: an unrecognised system resolves to None and sends no parameter at all, where an open value space would turn every unrecognised string into a permanent empty grid. Every Linux resolves to `linux` — an unusual choice of distribution must not exclude somebody from the general Linux grid. `omarchy` is deliberately absent (issue 04 owns taking the measurement first). This is NOT `host.platform_kind()` and the two must not be merged: that one answers "which binaries run here" and splits macOS by CPU generation, which would put Apple Silicon and Intel users in different communities. A⚠️ on that function now says so. The value is a CLAIM, never a fact — the same class as `device_id`. The gate it feeds stops mistakes, not intent. No rollout ordering in either direction: `os=` is a new key on an existing endpoint, so an old control plane ignores it and a new one facing an old CLI issues nobody an OS-grid token (D-j). Both directions are tested. Also pins grid-apis `store.py` as the THIRD copy of the `os-community` literal in the cross-repo lockstep suite, and records the amendments review turned up in ADR 0039: D-i refuses project creation alone (not task creation too), D-e's roster claim is narrower than "reports nobody", the re-type residue is self-minted membership rather than access granted by hand, and the refresh path's refusal is destructive rather than a clean gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017grXGJhrS2joydZFsWE9iU
…eter name Two findings from the review of 8823e2b. THE RECOVERY ADVICE WAS INVERTED ON EXACTLY ONE TYPE. `grid_credential`'s 403 branch told the user their refusal was about membership and that `grid login` would not change it. True for every pre-existing network type, and backwards for `os-community`: there membership is re-derived from the `os=` claim that only `fetch_tokens` sends, so signing in again IS the only recovery — and the CLI was steering people away from the one command that would have fixed it. THE `os` PARAMETER NAME IS A CROSS-REPO WIRE VALUE AND HAD NO PIN. This feature added a second hand-duplicated value besides the type literal: the query parameter's NAME, written independently as `params["os"]` here and `alias="os"` in grid-apis. Rename it on one side alone and both repos stay green — this repo's test asserts what the CLI sends, grid-apis' asserts what it accepts, and neither notices they no longer agree. It degrades SILENTLY: the parameter is ignored and nobody is issued a token, which is the class CLAUDE.md's register says must be pinned rather than reasoned about. Now pinned against grid-apis' decorator, with a positive control, and raising rather than skipping when the sibling worktree is misconfigured.⚠️ The pin matches BOTH `ast.FunctionDef` and `ast.AsyncFunctionDef`. `async def` is a separate node type that is not a subclass of the sync one, and grid-apis already spells 15 of its routes that way — so matching only the sync kind made an ordinary `async def` refactor, which moves no route and breaks no seam, report drift that had not happened. Verified with three controls: without the fix an async refactor fails, with it there is no false alarm, and moving the route's path still fails. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017grXGJhrS2joydZFsWE9iU
The CLI half of issue 10. `refresh_network_token` now reads `os_grid.os_token()` itself and puts the claim in the refresh body, so a machine on an `os-community` grid renews without a person running a command. It omits the key entirely when this machine claims no OS — the same discipline as the fetch, so the far end never has a second spelling of "no claim" to recognise. The serve loop needed no change and that is the design: `_ServeState.refresh` passes no claim of its own and inherits one because the helper reads the single decision point. Asserted there rather than at the helper, since the loop is the caller a `network_epoch` bump actually reaches — a refactor that threaded the claim through the loop's arguments would leave the helper's test green and that one red, which is the right way round.⚠️ THE FETCH'S PIN DOES NOT COVER THIS ONE. The claim rides the sign-in as a query parameter and the renewal as a JSON body key: two spellings, written in different places on both sides, drifting independently. Two new lockstep cases — the renewal's key against grid-apis' model, and one asserting this CLI's own two call sites agree with each other, because a machine announcing `os` on sign-in and `os_token` on renewal would be admitted and then silently unable to renew. Both verified by mutating each side separately.⚠️ The agreement case takes a monkeypatch CONTEXT per call, and that is not tidiness: both helpers patch `httpx.Client` and capture the real one first, so under one patcher the second captures the first's replacement, its handler never fires, and it reads an empty body. The helper's positive control caught exactly that instead of letting the comparison report a verdict it had not measured. Also pins `test_control_plane_refresh_network_token_posts_refresh_unauthenticated` to a fixed `platform.system()`. Its exact-body assertion had become machine-dependent — it failed on a Mac and would have passed untouched on a BSD — so it is pinned rather than loosened to a subset check, which is what still catches an unconditional extra field. ADR 0039 D-e records the decision, the rejected alternative and why it could not work, and the measured both-ways degrade. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017grXGJhrS2joydZFsWE9iU
D-l answers issue 08: `GET /relay/v1/grid/members/usage` is refused on `os-community`, the same at-the-door shape as D-i. Its gate is `inference:models` — the scope every consumer holds — so on a grid that keeps no allowlist it reduces to "do you hold a token here". On this type that is every stranger running the same operating system, and the route returns each member's email alongside their spend. D-e did not already cover it: the roster is empty by construction because no OS membership is persisted, but this route's emails come from observed traffic (`node_answered_query.member_snapshot()`), which no membership model can empty. D-e now points at D-l so the next reader does not stop there. The app being the route's only reader is not containment either — the exposure is the route, and every member on this type can call it. No rollout order and no half in this repo; grid-src carries the refusal.
D-m answers issue 14: `provider_email` is sent as `null` on `os-community`, and the key stays. `GET /relay/v1/grid/overview` is public and unauthenticated and names every provider by email. On a private-domain grid that is a company publishing its own people's machines; on this type it is the address of every stranger who serves, readable by anyone who knows the URL. This is the THIRD mechanism onto one exposure, and the door left open was the WEAKEST-gated of the three: D-e empties the roster by construction, D-l refuses an authenticated route, and this one has no gate at all. That ordering is not a decision anybody made — it is the shape of finding the mechanisms one at a time, so D-m also writes down the question to ask before the fourth. The key stays and only the value goes: a payload shape that varies by network type is the cost D-l already rejected. Overloading `null` is deliberate — it already means "the owner could not be resolved", and a distinguishable "withheld" would advertise that there is a name to ask for. No app half and no rollout ordering, measured: `OverviewNode.providerEmail` is already nullable and documented as null on an older relay, and `node_groups` collects unattributed machines into one headless block. The app's own `nodeHostHandle` already carried the comment that the overview "is unauthenticated and carries the whole address" — the relay-side decision was the missing half. Rejected: a stable pseudonymous handle (stable across polls IS a durable identifier), authenticating the overview (every member holds a token, so it converts an unauthenticated leak into an authenticated one — what D-l refused), and accepting it (running `grid join` says nothing about publishing an address). D-l gains a forward pointer so it does not read as the last word.
…y found D-m closed by saying nobody had enumerated every reader of UserRow.email on the relay, so "no fourth mechanism found" was not yet "no fourth mechanism exists". Issue 15 does that enumeration; this records its answer so the next reader does not repeat it. Six sites emit an address. Two are closed by decision (D-l, D-m), three are unreachable behind D-i, and one is a token's own subject handed to its own holder. There is no roster route on the relay at all — list_grid_members is grid-apis'. D-i's premise carries three of the six, so it was tested against the paths its own AST scan admits it cannot see: apply_sync_snapshot writes no ProjectRow (the most plausible one, being the writer driven from outside the relay), no raw or bulk insert into projects exists outside tests, and no migration seeds one.⚠️ One residue D-m did not consider: the node `name` on the same public payload is `opts.node_name or platform.node()` — the machine's hostname — and consumer systems commonly derive a default machine name from the owner's. That is weaker than an address and is NOT measured: the machine this audit ran on does not show the pattern. So D-m is left as it stands rather than amended on an unmeasured claim, and issue 15 carries the measurement to take on stock macOS and stock Ubuntu plus three candidate answers.
ADR 0039 D-h decided the denylist is widened to `os-community`; issue 06 built it. Neither half lives in this repository — grid-apis decides what a caller sees and is issued, grid-src decides what a credential already in flight is served — so what belongs here is the decision record and the pin that neither repo's own suite can carry. D-h gains an As-built note: the role condition moved into a named per-type answer on each side (`store.denylist_governs`, `grid_auth._is_open_consumer`), so "which types does the denylist reach" and "whom does it bite there" are asked separately rather than one inferred from the other. It also records the two consequences that are not obvious from the decision — the owner of an OS grid is deniable rather than exempt, and every arm of `member_for_access` that synthesizes a member has to ask (the owner arm was missed on the first pass and found in review). `test_the_denylist_reaches_an_os_grid_on_both_sides_of_the_call` parses `DENYLIST_NETWORK_TYPES` out of BOTH sibling repos with `ast`, with a permissioned-providers positive control on each. It reads the type SET only — the role condition is a function body spelled differently on each side on purpose, and each repo pins its own against a `both` member. Proven by dropping `os-community` from either side, which kills the case for that side.⚠️ Like every cross-repo check here it skips unless the sibling worktrees sit beside this one, i.e. it skips in CI. Run locally with all four present: 13 passed. Full suite 3247 passed, 0 failed.
The public CLI's half of ADR 0039 D-k (issue 07). Three different things leave somebody with no OS grid in their list, and until now they shared one symptom that neither a support conversation nor the person in front of it could take apart. `cli/os_grid_notice.py` is the new module. It answers cause 3 — a machine whose operating system resolves to no token — LOCALLY, deciding it before `os_served` is looked at at all. That independence is the point, not an optimisation: it is parametrised over every answer the far end can give (`True`/`False`/`None`), so a refactor folding it into the `os_served` branch is caught. Causes 1 and 2 become one line from the control plane's new key. `fetch_tokens` now returns a frozen `TokenFetch(networks, os_served)` rather than a bare list — one door onto the request, not a second helper that guarantees nothing. Every test stub therefore models the reply, which is a feature; the two in gated tests that assert the call never happens were converted too rather than excused, because a stub that cannot be copied safely is the next person's bug.⚠️ `os_served` is a TRI-STATE and `None` is not `False`. Collapsing them — the obvious tidy-up — puts "this control plane isn't serving one" in front of every user of every deployment that has not shipped the key yet, the one direction D-k exists to keep quiet. Compared by identity against the two booleans; `"false"` is truthy in Python, so a truthiness test reads a refusal as a service.⚠️ `--json` gained an always-present `os_grid` key. That IS a compatibility event — the one part of this an older client can see — and it is recorded rather than discovered later. Always-present rather than emitted-only-when-there-is-something- to-say, because a key that comes and goes is one every script has to guard. A fourth cross-repo pin joins the lockstep suite. `os_served` degrades silently in BOTH directions (an unknown JSON key is dropped by everybody), so a rename on one side leaves both repositories green with the feature explaining nothing. The pin reads the keys grid-apis returns off its `return` dict literal — the route declares no response model, so the literal is the contract — then probes which of them this CLI actually reads by ANSWERING with each in turn. Verified by mutation on each side separately, plus a case proving `OS_SERVED_KEY` has not drifted from the parser. `os_token()` now resolves from `system_name()`, so the module makes a single `platform.system()` read: the name a person is shown and the value the gate compares can no longer come from two readings four lines apart. Recorded rather than done, so nobody "fixes" them back: - The line is NOT printed when the command itself fails. A failed `grid sync` should report its failure; burying the actionable sentence under a side fact serves nobody, and the line arrives on the next command that succeeds. - `grid ls` gained nothing, though D-k's opening names it. The local cause is free there; the control-plane cause is not, because nothing persists `os_served`, and explaining one cause while silent on the other is worse than explaining neither. - The claim is re-derived by `absence()` rather than threaded through `TokenFetch`: being answerable without a round trip is the property criterion 1 asks for. Review found four defects, all in tests, none visible by reading — the two-reasons test compared whole sentences (satisfied by the machine's name, since the two cases can never share a machine); the fix for that over-blanked with `str.replace` and let the mirrored mutant live; and criterion 4 keyed on the phrase "OS grid", which would have gone vacuous on any reword of the sentence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SuK1jcCXSjVwzNak87S4dw
D-m nulled the provider's address on the public overview and deliberately left the machine described. Issue 15's enumeration found the machine's own name on the same unauthenticated payload, and consumer systems derive a default machine name from the owner's — so that field can carry a person's given name with nobody having chosen to publish anything. Measured 2026-09-03 on a never-renamed Mac (macOS 26.6): Setup Assistant writes `<first word of the account's full name>'s <model>`, and the value reaches `platform.node()` verbatim. A sweep of all 39 prod masters' own overviews found one live node published under exactly such a default. D-n: on `os-community` the overview publishes a name only when the provider states its operator chose it (`name_chosen` on the register meta); otherwise the anonymous label the route already computes. Withholding every name was rejected on the same measurement — real operators do name their machines, often after their people — and a relay-side heuristic was rejected because it is wrong in both directions on the fleet actually observed. Records two facts the audit did not have: the residue has TWO provider halves (grid-src `lifecycle.py`, and the public CLI's `socket.gethostname()`), and the app needs no half. The Linux measurement stays open and can only widen this, not overturn it.
D-n was decided on the macOS measurement alone and said the Linux half could only widen it. Reading the installers: Ubuntu desktop generates `<username>-<product>` and leaks the same class; Omarchy sets no hostname at all, so an Omarchy box inherits archinstall's constant `archlinux` and leaks nothing; windows is still unmeasured and was never in issue 15's scope. The fleet as deployed is provisioner-named throughout. Marked as source reads rather than measurements, since that is what they are.
ADR 0039 D-n, issue 16. On an `os-community` grid the relay's public, unauthenticated overview publishes a node's name only when the provider states its operator chose it; every other network type is unchanged. This is the public CLI's half of that statement — the binary an OS-grid member actually installs, which is not the grid-src runtime that runs on the fleet — plus the cross-repo pin holding all three copies of the key against each other. - `--name` and the hostname stop being one string at the join. The run record gains `meta_name_chosen`, and `remote/serve._meta` states it on the wire as `name_chosen`, read by identity against `True` so a record written before this change reads as NOT chosen — the fail-closed direction, and the only thing standing in for a 404 nobody gets on a merged payload. - The no-op gate compares the claim, but only when the record STATES one. `--name mybox` on a box already called `mybox` must act — same string, opposite fact — while a record from an older build must not turn every re-join that passed `--name` into a reload nobody asked for. Two questions, two readings of absent. - The mirror direction is the only silent one, and it now speaks: dropping `--name` on a box whose hostname matches the chosen name says so on stderr, naming no network type (this repository holds no `os-community` constant). - `tests/test_os_grid_type_lockstep.py` compares the key's spelling on both sides against each other — the relay's read off the `published_node_name` call, this CLI's measured off two metas that differ only in that fact — and against grid-src's other provider half. Verified by mutating all three copies separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZ33AtDj2SctoLg37yYsy8
Found reviewing issue 16; the defect is in issue 07's slice.
`fetch_tokens` fell back to `payload = {}` for a 200 whose body is not a JSON
object, so a malformed answer read as "zero grids" — and `cmd_sync` overwrites
`[[networks]]` authoritatively, discarding every grid's access AND refresh
token. Refresh rotation makes those unrecoverable, and the only signal was a
stderr line calling the wipe possibly transient.
`_validated` could never catch it: it is the trust boundary for bad ROWS and
iterates the list, so a non-object body yields nothing to validate and sails
through. The check now sits one level up, on the shape that holds the rows, and
raises `ControlPlaneError` — a `SystemExit` subclass, so "never a crash" (the
reason the fallback was added) still holds without the credential loss. The same
refusal covers a body that will not parse, an empty 200, and one nested past the
parser's limit; `RecursionError` is caught beside `ValueError` because
`json.loads` raises it and no `except ValueError` would see it.
The wording is pinned against `cli.auth._SESSION_EXPIRED_RE` so a broken 200
never sends somebody to `grid login` for a fault no credential can fix.
⚠️ This overturns the rule `test_fetch_tokens_survives_a_body_that_is_not_an
_object` encoded, so that test is rewritten with the reason rather than a second
one added beside it, and a negative control pins that an ordinary
`{"networks": []}` still clears the list.
Also, in the same review, the "no OS grid" line was reported as firing for every
user of a deployment with OS grids switched off. That is ADR 0039 D-k's decision
— the first of four states `false` deliberately collapses — not a defect. What
was wrong is `os_grid_notice`'s own "Quiet in the ordinary case" bullet, which
read as though that case were exempt. The bullet now says what D-k decided and
`test_a_deployment_serving_no_os_grid_at_all_still_says_so` pins it, because
suppressing the line there would also suppress it for a provision that failed
and left the row `pending`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZ33AtDj2SctoLg37yYsy8
Sync-down before the PR, so the feat x main integration lives on the feature branch itself. Brings v0.3.28 and the Z-Image media route. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZ33AtDj2SctoLg37yYsy8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ships the public CLI's half of ADR 0039 — a grid can be keyed on an OS: a machine claims
its operating system when it signs in, and is admitted to the community grid for that OS.
What is in THIS repo
shared/system/os_grid.py— the closed token set (macos/windows/linux/omarchy) andos_token(). No env override, deliberately: the claim is derived from the machine, not typed.remote/control_plane.py— sendsos=onGET /v1/grid/tokens, and a second spelling ofthe same claim on the renewal body. The two are written in different places on both sides and
drift independently, so both are pinned.
cli/os_grid_notice.py— three causes leave somebody with no OS grid; this says which, and staysquiet in the two cases that are not this feature's answer.
os_servedis read as a tri-state:None(an older control plane did not say) is notFalse(it said no).remote/serve.py—name_chosenon the register meta. Anos-communitymember runs THIS CLI'sprovider half, not grid-src's, so this is the half that decides whether a stranger's hostname is
published on a public page.
fix:a malformed token answer no longer empties the credential store — latent on every gridtype since the route was written, found because
os-communityreaches it on every refresh.Cross-repo — read before deploying
Three repos, no code dependency, every seam is wire-level. Deploy order is part of the product:
grid network create --network-type os-communityand that binary IS grid-src. Wrong order ⇒ argparse exit 2, a grid stuckpendingretrying forever.
ineffective against the account it was written for, for up to a year.
os=is a new query parameter on an existingendpoint: an old control plane drops it, a new one reads an old CLI's silence as claiming nothing.
Verification
tests/test_os_grid_type_lockstep.py(new, 931 lines) compares the two repos' spellingsagainst each other, not against a constant either side can move with its own tests.
main(v0.3.28).process rather than from git: admission by a non-owner; the same account on two machines getting
two different OS grids and neither seeing the other's; task plane refused at its single gate;
member-usage panel 403 against a 200 control; provider e-mail and machine name withheld against a
control grid publishing both from the same relay build; denylist biting both halves and releasing
again; a real completion served free.
Known open, deliberately
windowshostname derivation is unmeasured (issue 15's last box). It does not gate this:D-n withholds regardless of what any platform's default turns out to be.
os-communitygrid is a separate slice (issue 09), named in D-i.🤖 Generated with Claude Code
https://claude.ai/code/session_01CZ33AtDj2SctoLg37yYsy8