Skip to content

feat(auth): login mints a durable user-scoped credential, one per machine - #600

Merged
indykish merged 63 commits into
mainfrom
feat/m136-live-connector-proof
Aug 14, 2026
Merged

feat(auth): login mints a durable user-scoped credential, one per machine#600
indykish merged 63 commits into
mainfrom
feat/m136-live-connector-proof

Conversation

@indykish

@indykish indykish commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Intent

An operator who logs in once keeps using the terminal — as themselves, against the deployment they logged into — until they log out.

agentsfleet login ran a correct Elliptic Curve Diffie-Hellman (ECDH) device flow and then persisted the wrong thing: the session JSON Web Token (JWT) recovered from the handshake, valid for about a minute. A terminal went stale while its operator was still reading the success message, and no renewal path exists — the Command-Line Interface (CLI) carries no Clerk Software Development Kit (SDK) and structurally cannot refresh a browser-coupled token.

Two workstreams land from this branch by Indy's call (Aug 11, 2026): M160_002 (durable credential) and M136_001 (live connector proof).

Landed

M160_002 — every section, every Dimension DONE. Specification in docs/v2/done/.

§1 — a credential that names the human. New core.cli_credentials (schema 250) with a partial unique index making two live credentials per (user, machine) unrepresentable. Only a hash is stored. Mint, list, and revoke endpoints; the credential joins the accepted principal set resolving to a user rather than a tenant.

§2 — login spends its sixty seconds on something that lasts. The recovered session token buys one POST /v1/cli-credentials call, and the durable afc_ credential that comes back is what reaches disk. The exchange completes before anything is saved, so a failed mint leaves the operator logged out and told why rather than logged in with a credential already dead.

§3 — one machine, one live credential; logout ends it. A second login from the same machine revokes what it left behind. Logout revokes this terminal's credential and deliberately leaves browser sessions alone. login --token and its piped-stdin seeding are gone; an unattended caller reaches the Application Programming Interface (API) with AGENTSFLEET_API_KEY alone, writing nothing to disk.

§4 — a credential remembers which deployment minted it. Un-parked and landed on Indy's Aug 14 call ("yes b is fine"). Shipped as one guard rather than the two originally designed: Dimension 4.2's refusal proved unreachable, because an inferred target resolves to the stored deployment and the two cannot disagree. What remains is the leg that reached production in silence — no stored deployment and no named target — refused as DEPLOYMENT_UNKNOWN before anything is dialed. guardCommand in cli/src/program/auth-guard.ts owns the whole policy; logout and doctor stay reachable.

§5 — one writer, one spelling. Folded in on Indy's Aug 13 call. scopes is the only capability claim read; the scope-before-scopes fallback was live and mis-ordered on the authorisation path. OIDC_PROVIDER=custom is refused at boot.

§6 — a tenant key carries its creator's capabilities. Folded in on Indy's Aug 13 call. An agt_t key resolves through clerk_scope_resolver keyed on the created_by subject the row already held, and the compiled-in TENANT_API_KEY_GRANT is removed rather than left unread.

M136_001 — closed with the live proof deferred. Specification in docs/v2/done/. §4 (replay closes the architecture marker) and §5 (the real reviewer proves the Live Wall, Playwright specs) are PARKED on Indy's Aug 14 call, quoted in the specification header: "I cant keep in active M136_001 - move it to Done"; §4: "i want to take it later".

Session notes

Two security defects found while building the exchange, both fixed here

Unbounded, self-renewing credentials. Minting accepted an existing credential as its authorisation (.jwt_oidc, .cli_credential), and machine_name is client-supplied and is the uniqueness key. The mint path carries no per-user ceiling and no rate limit — both verified by reading the handler and store, not assumed. Chained, that turns one stolen credential into an unbounded supply: each mints the next under a machine name of the caller's choosing, revoking any single row leaves its siblings live, and the account holder cannot tell how many exist. Since the credential is durable, a compromise that previously expired in sixty seconds became permanent.

Minting now takes a browser sign-in alone — the one step a stolen credential cannot replay. Listing and revoking still accept a credential, so a terminal can end its own access without opening a browser.

Prefix-only validation. The stored value was checked by prefix on both the load path and the mint-response decode. A value carrying trailing bytes passed. Both now match the full declared shape (afc_ plus exactly 64 lower-case hexadecimal characters), mirroring looksWellFormed in src/agentsfleetd/auth/cli_credential.zig — the mechanism the spec cites Supabase's access_token.go for.

Decisions

  • The load allowlist admits agt_t as well as afc_. Per the spec's Failure Modes, a tenant key resolves as a tenant principal and is refused at the route, not at the file; only a session token is refused on load. A literal reading of Dimension 1.4 would have broken login --token.
  • UZ-AUTH-025 is registered with a reachable: no annotation naming the client as producer. The spec already intends client-produced registered codes (§4's deployment-mismatch refusal never leaves the process), so this follows its decided design. UZ-AUTH-021 was deliberately retired by f64e20c and was not reused.
  • A refused mint preserves the daemon's own error code rather than flattening every cause into the client's. The client code covers only causes that carry none.
  • machineName derives from the hostname, not the platform. defaultTokenName() returns "macos-cli"; using it would have made every Mac claim the same row and break Dimension 3.2.
  • The empty-credentials literal moved to emptyCredentials() — a factory, not a shared constant, because loadCredentials hands its fallback back by reference and a caller mutating what it read would corrupt the next reader's.

Verification

  • make test-unit-cli — 1443 pass, 13 skip, 0 fail
  • make test-unit-agentsfleetd — 2205 pass, 287 skip, 0 fail
  • make test-integration — exit 0; filtered run of the new security test reports 76 pass / 0 skip, so it executes rather than skipping on an unavailable datastore
  • make check-version — all versions match 0.26.2
  • gitleaks — clean; every credential-shaped fixture is built by repetition so no high-entropy literal enters the tree
  • Depth gate: integration 598 → 599

Caveats, stated rather than buried

  • The mutation probe on the mint restriction never ran. Both the production-side and test-side mutations were blocked by the harness classifier as security-weakening edits. The test provably executes and passes, but "proved to bite" is not a claim this one has earned. Worth a reviewer's eye.
  • The spec's rubric commands use --test-filter, which this Zig version rejects. The working form is make test-integration TEST_FILTER=<substring>. The rubric rows need correcting before they are graded.
  • docs/AUTH.md still says the afc_ branch and mint endpoints are "not yet wired". Stale as of §1; it is in the Files Changed table and will be corrected at DOCUMENT.
  • A latent test weakness was fixed in passing: several fixtures seeded a three-segment session token into the credential field. Four specs failed outright once the built binary was refreshed; two others were passing only because they assert the api_url rung and never noticed they had become logged out.
  • make memleak and the cross-compile rows have not been run on this diff yet; they are due before this leaves draft.

🤖 Generated with Claude Code

Session notes — Aug 14, 2026 (memleak fix + adversarial sweep)

CI red on the memleak lane — root-caused and fixed. The boot-drain lifecycle
test failed SseUnexpectedStatus after the resolved-capability change: the
seeded agt_t key's creator (user_lifecycle_test) has no identity-provider
record, and the real serve boot hardcoded the vendor API base, so the offline
lane could never answer the scope resolve. The leak dump in the job log was
fallout (test erred before its drain; every daemon thread was still alive at
exit). Fix: CLERK_API_BASE boot override resolved once beside the secret and
threaded to BOTH the scope resolver and the signup metadata writer, plus a
loopback FakeClerk in the lifecycle test answering the creator claim. The
boot-drain now proves the resolved model end to end: agt_t auth → creator claim
over real HTTP → scope closure → SSE held through SIGTERM → clean drain.
make _memleak-boot-drain green locally (valgrind-free on macOS; CI runs the
gate).

Adversarial sweep (Indy's ask, no spec): 17 findings fixed.
TypeScript: dead JWT-claims path deleted from auth status (every credential
is opaque post-resolve; JSON envelope drops token/credential_kind);
credentials.json read once per command via a snapshot accessor (was 3
reads; logout's token+credential-id could span two snapshots); hydrate's
duplicated save block unified (+ dead guard removed); reasonOf single-sourced
in errors/index.ts (was 4 copies); emptyWorkspaces() kills a live drift bug
(cli.ts fallback had lost tenant_id); dead getApiUrl/authRole/
extractRoleFromToken deleted; isString de-cloned for PR files
(lib/guards.ts); CLI_CREDENTIAL_TOTAL_LEN now pinned by a test.
Zig: CLI-credential auth JOIN now returns u.tenant_id (authoritative) onto
the principal — resolvePrincipalTenant stops re-querying core.users on
every workspace-scoped CLI request (~15 handler paths); scope-resolver cache
Mutex → RwLock (shared reads on the hot path of both resolved credential
classes); five route-matcher clones collapsed into two shared shapes;
redundant (user_id, revoked_at) index removed from the new credentials
schema (both readers served by the partial unique index); stale "API keys
never touch Clerk" paragraph in docs/AUTH.md corrected.

Deferred (agent call, listed for Indy's veto or a follow-up slice):

  • Login pingMe → fold into hydration's first fetch (drops one round trip;
    changes specced fail-loud semantics + acceptance pins).
  • Logout dual DELETE → one endpoint (needs new/extended public API + docs).
  • resolveHashedCredential generic to dedupe the two credential middlewares,
    and a shared clerk_http.zig for the backend fetch pair — coherent slice
    with ScopeFn returning a parsed scopes.Set (kills the per-request
    claim re-parse) and per-subject single-flight (resolver doc updated with
    the caveat meanwhile).
  • Mint path: 5 statements → data-modifying CTE; principal user_row_id to
    skip resolveUser on list/revoke.
  • c.deployment stays on the auth SELECT despite no current reader — the
    deployment-binding dimension (§4) reads it at exactly that site.

Coverage: merged Zig line coverage 88.30% (was 87.80% at handoff; 83% gate
passes; Indy's 90% target not yet reached). Next targets by missed lines:
serve.zig 111, tenant_provider 53, serve_webhook_lookup 48,
tenant_model_entries 47, session_store_redis 45, platform_keys 42.

Test Delta: unit 3512 → 3741 (+229), integration 589 → 629 (+40) vs the
CHORE(open) baseline. CLI: 1434 pass / 0 fail. Preflight integration tests
mutation-probed red→green against live datastores.

Still owed on this branch: M160_002 §4 (deployment binding), M136_001 §5
(Playwright), ~/Projects/docs changelog branch (breaking scope-inheritance
change, --token warning rewording, auth status envelope change, logout
reason strings).

Decisions — Aug 14, 2026 (Indy, verbatim)

  • M136_001 §5 parked: "just make it parked i will ask another agent after
    this PR is merged to implement/execute/ run the scenario via playwright, so
    record in your dimension towards that effect" — recorded in the spec section
    header; dimensions 5.1–5.4 are the follow-up agent's work order.
  • Docs-repo changelog waived for this PR: "i dont need a change log
    update, but any update needed in the CLI is needed" — CLI-side prose updated
    instead (auth status description, README login row, orphaned role constants
    removed) in b6edd41.
  • M160_002 §4 parked: "Yes think park this, the error will say it to check
    the api_url as well when it fails is the simple fix here." — simple fix
    landed in 6b6dab5 (401/403 suggestions + auth status rejection line now
    name the target API URL); the guard's full design is recorded in the spec
    section as the follow-up work order.

Session notes — Aug 14, 2026 (session 3: review-fix batch landed)

Landed as e4db078ba. Every finding from the Aug 14 adversarial chain is now committed and verified on this tree.

Review outcome — 16 findings fixed (Claude adversarial: 9; Codex adversarial: 7), plus greptile's one P1 (concurrent mint race, replied on the thread). Three were closed as decisions rather than code:

  • Codex C3 — tenant snapshot during provider latency. Accepted semantics: a snapshot taken at mint reads the same as api_key mode does today. Noted, not changed.
  • Codex C5 / Claude chore: simplify README comic and apply pending updates #6auth status JSON envelope break. Indy waived the docs/changelog obligation for this PR: "i dont need a change log update, but any update needed in the CLI is needed."
  • Claude feat: iamborn alpha v1 merge to main #2 — in-place index edit. Moot: schema slot 250 never merged anywhere, so there is no deployed index to migrate.

Three defects found while verifying, not inherited from the review:

  1. RULE FLL was red. auth/clerk_backend.zig reached 355 lines against the 350 cap. Split per write_zig.md §Module Split Pattern into auth/clerk_backend_config.zig; clerk_backend re-exports the surface and remains the public API, so none of the six call sites changed.
  2. sql.zig doc-comment mis-attachment. LOCK_CLI_CREDENTIAL_MINT had been inserted between INSERT_CLI_CREDENTIAL and its doc comment, so the partial-unique-index rationale documented the lock and the insert carried none. Reordered; the lock's parameters are now pinned $1::text || ':' || $2::text rather than relying on Postgres unknown-type inference.
  3. docs/AUTH.md rotation runbook was wrong. It stated agentsfleetd does not present CLERK_SECRET_KEY directly. It does — auth/clerk_scope_fetch.zig on every authenticated command-line request that misses the scope cache, and handlers/auth/identity_events_clerk.zig on signup. A rotation that skipped the Fly redeploy would fail scope resolution closed once warm cache entries aged out. Fixed, and CLERK_API_BASE is now documented beside the compromise model it governs.

Verification on e4db078ba:

Lane Result
cd cli && bun test 1438 pass / 13 skip / 0 fail
zig build test exit 0
make test-integration TEST_FILTER='divergent mint snapshot' full integration suite passed
make _memleak-boot-drain boot→SIGTERM→drain ran leak-clean under the gate
make lint-all ✓ all lint checks passed
Test Delta vs CHORE(open) baseline unit 3512 → 3746 (+234) · integration 589 → 630 (+41)

Docs audit (Indy's punch-list ask, landed 0ff091470). Every contributor doc reviewed against the brief — brief, agent-locatable, human-skimmable, no duplication of docs.agentsfleet.net, link out where user coverage exists. Changed: docs/AUTH.md (question→anchor lookup index; scope catalogue names scopes.zig as canon and links the published subset; runner token corrected to the fifth surface; plus the CLERK_API_BASE section and rotation-runbook fix from e4db078ba) and docs/architecture/README.md (fleet key retired at M154 §8 removed from the AUTH row; Facts-table claim narrowed to what's true on disk). Audited clean, untouched: README.md, docs/development.md, docs/AUTH_DEVICE_LOGIN.md, the architecture topic files. One cross-repo finding for Indy: published api-reference/scopes.mdx lists fleetkey:read/fleetkey:write, which do not exist in auth/scopes.zig — needs its own ~/Projects/docs branch, not this worktree.

Tracked for Indy, not implemented — CLERK_API_BASE threat model as a class. The end-to-end analysis is settled for this variable: the override is read once at boot, so an attacker who can set it already owns the daemon's environment and could read CLERK_SECRET_KEY outright. What the loopback-termination rule actually closes is the review bypass — http://127.0.0.1.attacker.example and http://localhost@evil.example read as localhost to a human eyeballing a manifest or a boot log, and a prefix-only check would have shipped the admin secret in cleartext on every scope-cache miss. The open question Indy owns: whether the same reasoning applies as a class to the other *_API_BASE / *_URL overrides that ride hx.ctx in the daemon (the github and slack connector bases), and whether the test-lane override should be a build-time flag rather than an environment variable. Decision wanted class-wide, not per variable.

Session notes — Aug 14, 2026 (session 4: close-out, merged)

Review is closed. Greptile raised exactly one finding across the branch's life: the P1 concurrent credential replacement race at src/agentsfleetd/state/cli_credentials.zig:110, fixed in e4db078ba with a transaction-scoped advisory lock keyed on (user_id, machine_name) and answered in thread. Greptile's re-review of 360559493 scored 5/5 with "no blocking failure remains". One review thread total, zero unresolved. Continuous Integration (CI) finished green on every check — no failures, nothing skipped that carried code. Merged by Indy as 92589429b.

Specification state at close. M160_002 Status: DONE, every Dimension across §1–§6 marked DONE. M136_001 Status: DONE with §4 and §5 PARKED under the quoted Indy call. Both in docs/v2/done/; docs/v2/active/ is empty.

Published documentation landed separately: agentsfleet/docs#173 on chore/m160-durable-credential-changelog. It carries the Aug 14 changelog <Update> and corrects four pages that this milestone made false — cli/configuration.mdx still told operators the login command accepts --token and piped input, cli/agentsfleet.mdx said logout "revokes every active session", api-reference/introduction.mdx said login saves a short-lived JWT, and docs.json had none of the three /v1/cli-credentials operations registered. The cross-repo fleetkey:* finding tracked above is fixed there too, along with the retired agentsfleet fleet-key commands the reference still advertised.

One defect this PR shipped, fix pending. UZ-AUTH-024's registry detail tells operators to run agentsfleet cli-credentials list — not a command in the client; only the Hypertext Transfer Protocol (HTTP) path exists. The same string also fails the docs repository's own checker on sentence length (DOC-02) and sentence count (DOC-03), so the generated error-codes.mdx could not lint until it was reworded. The generated page carries the corrected wording; src/agentsfleetd/errors/error_entries.zig does not yet, so the next make gen-error-codes would reintroduce it. Needs a small follow-up PR against main.

Cross-repository version drift, for Indy. make gen-error-codes stamps the agentsfleet repository version into the generated page's front matter (0.26.2), while ~/Projects/docs scripts/check-documentation.py hardcodes EXPECTED_VERSION = "0.25.0" and rejects anything else. Every regeneration now fails the docs checker until the pin moves across all 25 pages. Left as a decision rather than folded in.

Greptile Summary

The PR replaces short-lived session-token persistence with durable, user-scoped CLI credentials and adds their complete mint, authentication, listing, revocation, and logout lifecycle.

  • Adds the core.cli_credentials schema and database-backed credential operations.
  • Exchanges device-flow session tokens for durable credentials before persisting login state.
  • Resolves credential principals and scopes through the daemon authentication pipeline.
  • Serializes concurrent replacement mints per user and machine.
  • Updates CLI state, deployment handling, logout behavior, API definitions, tests, and authentication documentation.

Confidence Score: 5/5

The PR appears safe to merge because the previously reported concurrent credential replacement failure is fixed and no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
src/agentsfleetd/state/cli_credentials.zig Implements transactional mint, list, and owner-scoped revocation; the advisory lock fixes the previously reported concurrent replacement race.
src/agentsfleetd/state/sql.zig Adds credential persistence, lookup, revocation, listing, and transaction-scoped mint-lock statements.
schema/250_cli_credentials.sql Introduces hashed durable credentials with a partial unique index enforcing one live credential per user and machine.
src/agentsfleetd/http/handlers/auth/cli_credentials.zig Exposes authenticated mint, list, and revoke operations for user-scoped CLI credentials.
cli/src/commands/login-exchange.ts Exchanges the recovered browser session token for a validated durable credential before login state is persisted.
cli/src/commands/auth-logout.ts Revokes pending sessions and the current machine credential before clearing local authentication state.
cli/src/program/auth-guard.ts Adds deployment-aware command guarding while retaining explicit exemptions for login, logout, and diagnostics.

Sequence Diagram

sequenceDiagram
  participant CLI
  participant API
  participant DB as Postgres
  CLI->>API: POST /v1/cli-credentials with session token
  API->>DB: BEGIN
  API->>DB: Advisory lock(user, machine)
  API->>DB: Revoke existing live credential
  API->>DB: Insert replacement credential hash
  API->>DB: COMMIT
  API-->>CLI: One-time afc_ credential
  CLI->>CLI: Persist credential and deployment
Loading

Reviews (6): Last reviewed commit: "feat(m160_002): login names the deployme..." | Re-trigger Greptile

Context used (4)

indykish and others added 30 commits August 11, 2026 11:11
Moves the live connector proof workstream out of pending and into active
so implementation can begin on its own branch.

- Status: PENDING -> IN_PROGRESS
- Branch: feat/m136-live-connector-proof
- Test Baseline: unit=3512 integration=589, from `make _lint_zig_test_depth`
  at the branch point, so VERIFY's Test Delta row has something to compare
  against.

No implementation in this commit — CHORE(open) lands the four transitions
before any code, per the deterministic lifecycle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… it needs

The rubric asked for two incompatible things: R5-R7 demand live wall
evidence, while S3 confined the diff to three markdown files. Producing
that evidence means touching acceptance specs, so as written no diff
could satisfy both P0 rows.

The wall tests §5 assumed were already built do not cover it:

- `fleet-count.spec.ts` seeds synthetic fleets and only counts upward,
  where 5.1 needs the real reviewer Fleet plus a stop and a resume.
- `multi-fleet.spec.ts` asserts tile state and concedes in its own
  comment that the stream is unobservable here, where 5.2 needs the
  workspace-stream connection count.
- 5.3 and 5.4 have no coverage at all.

So Files Changed gains the two wall specs, one new reviewer walk, and
the acceptance config that schedules it; Applicable Gates stops claiming
N/A and names the TypeScript gates that now fire. §1-§4 remain external
proof and stay markdown-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erred to CI

Preflight against dev passes 10 of 13. The three failures are browser-side
only: `app-dev` sits behind Vercel deployment protection, so the browser
lands on the protection wall instead of the dashboard sign-in. Global setup
trades VERCEL_BYPASS_SECRET for a short-lived cookie, and that secret is in
none of the three 1Password vaults.

Everything API-side passes against `api-dev` — fixture provisioning, Clerk
session minting, seed/list/delete roundtrip, and a teardown that revoked
three sessions and swept zero leaks. The environment is live; only the
browser path is walled.

No Section is marked DONE: §5's acceptance specs are unwritten and §1-§4
external proof is unrun. Status stays IN_PROGRESS so a pickup agent reads
this as parked, not finished.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cally

The previous commit recorded local proof as blocked on Vercel deployment
protection. It is not, and leaving that on the record would send a pickup
agent hunting a secret they do not need.

Setting BASE_URL to the deployed dashboard routes the browser at a host
behind Vercel Single Sign-On, which answers 302 to vercel.com/sso-api —
hence three browser-side failures. Omitting it makes the config build and
serve the app itself while the API still points at api-dev. Run that way,
preflight passes 13 of 13, teardown revokes its sessions, and the sweeps
find nothing leaked.

VERCEL_BYPASS_SECRET is needed only for driving the deployed dashboard.
The canonical local invocation is now recorded in the spec.

Parking stands, but as a priority call rather than a blockage: M160_001
goes first, and this resumes with no further environment work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found live during M136_001: `agentsfleet login` reports success and the
credential is dead fifty-two seconds later, because what reaches disk is
the session token recovered from the handshake. The CLI carries no Clerk
SDK and cannot refresh one, so no renewal path exists or ever could.

Two layers. The Clerk `api` template is set to sixty seconds where the
dashboard's own carve-out comment documents roughly fifteen minutes —
operator configuration, and Indy's. And even fifteen minutes is the wrong
shape for a terminal, which is this spec.

The first draft persisted a tenant API key and was rejected in adversarial
review before commit: `core.api_keys` binds tenant_id NOT NULL with
created_by as free text, so a terminal would have authorised as the whole
tenant and attribution would have collapsed to a string. No user-scoped
credential exists to extend, so §1 adds one — one live per machine,
enforced by a partial unique index rather than by store discipline.

§3 and §4 are not optional extras: both answer failures a durable
credential creates and a sixty-second one masks — credentials accumulating
live on every re-login, and a deployment mismatch that stops
self-correcting once the token no longer expires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A durable credential can be handed to a colleague by the account holder,
who approves in their own browser and forwards the six-digit code. Every
control fires correctly, because the person consenting is the person
sharing — so it cannot be closed cryptographically and this spec does not
pretend otherwise.

Today's sixty-second token is incidentally the anti-sharing control, and
removing it is a commercial consequence worth having on the record.

The answer is attribution, not enforcement: three last_used_* columns
overwritten in place on the update the authenticate path already performs.
One credential stays one row, so "one seat, two machines, same hour" is a
query rather than a mystery. No concurrency limit, no seat counting — that
is a billing decision, better taken with the data than ahead of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moves the durable-credential workstream out of pending so implementation
can begin on its own branch.

- Status: PENDING -> IN_PROGRESS
- Branch: feat/m160-durable-credential
- Test Baseline: unit=3512 integration=589, from `make _lint_zig_test_depth`
  at the branch point, so VERIFY's Test Delta row has something to compare
  against.

No implementation in this commit — CHORE(open) lands the four transitions
before any code, per the deterministic lifecycle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…machine

The platform issues exactly one durable credential today and it belongs to
a tenant, so a terminal holding one acts as the whole tenant and the audit
trail records a free-text string where a person should be. This adds the
missing primitive.

user_id is a foreign key, the deliberate inverse of 240_api_keys' choice.
That table keeps a plain string so an automation key outlives the admin who
minted it — erasing a departed admin must not break nightly jobs. A personal
credential inverts the requirement: if the human is erased, every terminal
holding their credential must stop, or offboarding is theatre and a
credential shared with a colleague outlives the account it belongs to.

One live credential per machine is enforced by a partial unique index rather
than by store discipline, so a broken revoke-then-mint ordering fails loudly
instead of leaving two live credentials an operator cannot tell apart.

No updated_at and no last_used_at. Revocation is the only mutation the row
takes and revoked_at already records it, so updated_at would restate one
fact in two columns — the problem 240's CHECK exists to police. A
last_used_at provisioned for stamping that has not shipped is speculative
(RULE NDC), and stamping on the authenticate path would turn the hottest
indexed read in the system into a write. Attribution is a mint-time fact
instead, which is sufficient: a shared credential is minted on the sharer's
own machine, so it arrives as a second live row under one user_id carrying
a different machine_name.

The digest is plain and unsalted, which is safe only while the raw value is
unguessable — so full entropy from a cryptographic source is now Invariant 9
with its own test, rather than an assumption the schema silently rests on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The value `core.cli_credentials` stores the digest of. Two properties carry
weight and neither is decorative.

Entropy: the row holds a plain unsalted SHA-256, which resists inversion
only while its input is unguessable. So generation goes through
`common.secureRandomBytes` — this project's single entropy surface, and the
Zig 0.16 replacement for the removed `std.crypto.random`. Invariant 9's test
asserts the property rather than the shape: a generator drawing from a clock
or a counter would still produce well-formed output, so the test takes 512
draws and requires every value distinct and every body position to vary.

Shape: `looksWellFormed` is checked on LOAD, not only on write, so a
regression that persists a session token where a credential belongs is
refused at read instead of travelling on a request. The reference
implementation validates on load for the same reason
(`~/Projects/oss/cli`, `internal/utils/access_token.go:16`).

Also corrects this file's schema comment: 240's claim that `last_used_at`
stays NULL is stale — `cmd/api_key_lookup.zig:63` stamps it on every
authenticated request, mitigated with FOR UPDATE SKIP LOCKED. The decision
to omit the column here stands on its own reasoning, which now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Handler-side operations on core.cli_credentials. The authenticate-path
lookup is deliberately absent: src/auth/ is portability-locked and cannot
reach the datastore, so digest resolution belongs in cmd/ as an injected
callback, the way cmd/api_key_lookup.zig already does it for tenant keys.
auth/tests.zig exists to prove that wall, so putting it here would have
broken a gate rather than merely bent a convention.

The digest is derived, never accepted. `mint` is the only writer and hashes
the value it just generated; no path takes a hash from a caller. That
matters more than it looks: if a client could supply a digest, the digest
would BE the credential, and storing a hash would protect nothing.

`mint` revokes this machine's live row before inserting its replacement,
inside the caller's transaction. The partial unique index makes the
ordering load-bearing rather than advisory — skip the revoke and the insert
fails loudly instead of leaving two live credentials an operator cannot
tell apart. Doing both in one transaction also means a failed insert leaves
the prior credential live, rather than revoking a working terminal for
nothing.

`revokeById` scopes to the owner, so a credential belonging to somebody
else is indistinguishable from one that does not exist and an identifier
cannot be probed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Indy's call, Aug 11, 2026: M136 and M160 share a single branch and a
single worktree. M160's commits are replayed onto this branch and its
Branch field pointed here, since feat/m160-durable-credential is
retired by the consolidation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The store had never been type-checked. Nothing in the tree calls it, and
`tests.zig` referenced it with a bare `_ = @import(...)`, which evaluates
a module without analysing its function bodies — so `zig build` reported
success over code that could not build. Two genuine errors were hiding:

- `copyListed` passed `pg.Row.get`'s error union straight into
  `alloc.dupe`. In `.safe` mode `get` returns `lib.TypeError!T`, so every
  read needs `catch return error.DbRowShape`, exactly as
  `cmd/api_key_lookup.zig` does — that file compiles only because
  `serve_boot.zig` takes its `lookup` as a function pointer.
- `listForUser` built its accumulator with `std.ArrayList(Listed){}`,
  which Zig 0.16 rejects. Every other store uses `= .empty`.

`copyListed` now takes `pg.Row` concretely rather than `anytype`, per
write_zig.md's Pg Query Wrapper rule: an `anytype` row parameter is only
analysed on instantiation, which is what let the mistake hide.

A `refAllDecls` reference keeps every body analysed until real call sites
exist, and three statement-shape tests guard invariants without needing a
datastore: the list query cannot return `credential_hash` (Dimension 1.3),
and both revoke statements stay owner-scoped and live-row-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`schema/250_cli_credentials.sql` creates a partial unique index and a
lookup index, and neither was registered in DECLARED_INDEXES. The repo
requires every index to name the reader that justifies it, so the
integration suite refused the schema:

    UNDECLARED INDEX: core.uq_cli_credentials_user_machine_live
    expected 0, found 2

The unique index gets an explicit 'no query reader' note rather than an
invented one — it is not read, it is the enforcement of one live
credential per (user, machine), and an insert that skipped the revoke
fails against it.

Found by running `make test-integration`, which had not been run on this
workstream. Integration now passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven acceptance tests asserted "not authenticated" while the spawned
binary inherited the real HOME. `composeEnv` passes HOME through, and
with no `AGENTSFLEET_STATE_DIR` the CLI falls back to
`~/.config/agentsfleet` (`src/lib/state.ts` resolveStatePaths), so the
assertions held only while the developer running them was logged out.
On a machine carrying a real login they failed with the operator's own
workspace id in the diff. CI has no such directory and passed throughout.

`help-and-errors.spec.ts` already promised "no `credentials.json`, no
live API calls" in its header; this makes the file honest.

`makeEmptyStateDirSync` joins `state-dir.ts` as the inverse of the
stubbed fixture already there. Folded into M160 on Indy's call: this
workstream changes the persisted credential shape these specs observe,
so it is the one that would otherwise merge the incoherence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two layers, because the leak had two paths.

The preload now points AGENTSFLEET_STATE_DIR at an empty tmpdir when the
var is unset, so in-process tests stop falling back to
~/.config/agentsfleet. This sits beside the telemetry-off default already
there and shares its rationale: `runCli` reads `process.env` directly,
not the `env` it is handed, because `loadCredentials()` runs first.

That alone was not enough. Roughly ten sibling files write credentials
into whatever state dir is current, so a shared runner-wide directory is
pristine only until the first of them runs — the json-contract auth test
passed alone and failed in-suite. It now takes its own empty directory
and restores the prior value, matching the `withStateDir` shape already
used in cli-alignment and its neighbours.

CLI suite: 1429 pass, 13 skip, 0 fail; enforce-coverage PASS at line
100.00%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ten test files each carried a private copy of the same scope guard —
save the env var, mkdtemp, assign, restore in finally, remove the dir.
`helpers-cli-state.ts` already owned `withFreshStateDir` for exactly this
and said so in its header; the copies had drifted anyway, and
api-key-env's was named `withFreshStateDir` too, shadowing the shared one.

Two shapes existed, so the shared module now covers both:
  withFreshStateDir  — call-scoped (already present)
  useFreshStateDir   — beforeEach/afterEach, returns the dir accessor
  preserveStateDirEnv— save/restore only, for files whose tests seed their
                       own dir before the code reads it

Files needing extra setup delegate rather than duplicate: doctor-json and
api-key-env seed workspaces.json inside the shared wrapper. help,
cli-alignment, workspace-create and doctor-json also dropped private
`bufferStream` copies the same module already exported.

Deliberately left alone, with reason:
  cli-funcfill            — points the var at a FILE to test broken state
  services-coverage-fillers — chmods the dir 0o500 mid-test; cleanup order
                              against the helper's own rm would be fragile
  telemetry/{tracing,runtime,consent}, analytics.layer.fixture — save a
                              bundle of 5-11 env keys plus process.stdout
                              .isTTY; the state dir is one member, so the
                              helper would silently drop the rest

Net -132 lines. CLI suite 1429 pass, 13 skip, 0 fail; enforce-coverage
PASS at line 100.00%; tsc and oxlint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pool's saturated-acquire test asserts the wait lands within
SHORT_ACQUIRE_TIMEOUT_MS + ACQUIRE_TIMEOUT_OVERSHOOT_MS. At 20ms that is
a 20ms wall-clock window, and it failed on a loaded machine while the
pool behaved correctly — the acquire timed out and
acquire_timeouts_total bumped; only the upper bound tripped. It passed
three consecutive idle runs afterwards.

The bound's stated purpose is catching a recompute bug that re-sleeps the
full budget each iteration. That defect surfaces as a MULTIPLE of the
budget, so any ceiling strictly below 2 x 50ms still catches it. 40ms
puts the ceiling at 90ms: same detection, double the tolerance for
scheduler noise. Going higher would blind the assertion, so this is the
maximum safe value rather than a round number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three calls after the first full verification lane on the shared branch:

- 9c491ce (queue/ flake) stays, approved as an in-scope inclusion; the
  Files-Changed table now says so rather than leaving an unexplained
  unrelated file in a credential Pull Request.
- The never-compiled-module gap gets no new gate, no new script, no
  governance change. New modules carry a refAllDecls block; the existing
  check-test-reachability already compiles every test root, so that block
  is what makes them compile-checked. The repo-wide hole stays open by
  decision.
- M136_001 is un-parked. Both workstreams finish on this branch.

Also records how the store shipped two compile errors behind a green
build, since that is the reason the refAllDecls convention exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The credential proves WHO; the identity provider answers WHAT. A digest
resolves to a row, the row carries the owning user's oidc_subject, and an
injected resolver asks the provider for that subject's scope claim, which
feeds the same parseClaim the JWT path uses. No grant is authored in code
and no capability is stored, so a scope edit reaches a terminal the way it
already reaches the dashboard, and a narrowly provisioned collaborator is
not widened by running login.

agt_t is deliberately untouched. 240:7-10 records that created_by is not a
foreign key precisely so a key outlives the admin who minted it; binding
it to that admin's live scopes would revoke working automation when they
are narrowed or offboarded. The two credentials differ because their
ownership does.

Adding the enum variant made three exhaustive switches fail, which is the
type system listing the decisions rather than letting them default:
  - resolvePrincipalTenant  -> resolves through the user row, like a session
  - approval attribution    -> names the person, not a key
  - steer actor             -> names the person, not machine

Also corrects a convention I had wrong: principal.user_id carries the
provider SUBJECT, not the core.users UUID — that is what the JWT path puts
there and what 240 records for created_by, and downstream code resolves a
user by oidc_subject, so a UUID would have resolved nothing on a route
that looked authorised.

UZ-AUTH-023 registered for a revoked credential, distinct from a generic
refusal so the operator is told to log in again.

test-auth 252/252. Depth gate unit=3532.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #597 brought b44cdb1, which solves the same leak from a parallel
session and solves it better: composeEnv now defaults EVERY spawn to its
own store, where my per-spec emptyEnv edits shared one directory across a
file and only covered the two specs I had touched.

Dropped as redundant: the AGENTSFLEET_STATE_DIR override and its module
const in help-and-errors and flags-and-env, and the withFreshStateDir
wrapper in json-contract, which main already brackets at file scope.

Kept because they are additive rather than duplicated: the test/setup.ts
preload default, which covers in-process tests main's fix does not reach,
and the shared state-dir helpers ten files now migrated onto.

CLI suite 1429 pass, 13 skip, 0 fail; enforce-coverage PASS at line
100.00%; tsc and oxlint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AUTH.md said three grants are applied in code, keyed by credential
source. The afc_ credential is a fourth source that deliberately breaks
that pattern, and the doc had no room for it — which is why the design
got re-derived several times before anyone reread this file.

Records: why it differs from agt_t (240 decouples a tenant key from its
creator on purpose, so it must NOT track them; an afc_ credential IS a
person, so it must); what a fixed grant would have cost (a read-only
collaborator silently widened on their next login — a regression the
durability introduces, not one inherited); why the claim is not stored
(a snapshot makes core.cli_credentials a second store of a fact Clerk
owns, a webhook projection adds backfill and reconciliation to operate,
an in-memory TTL cache has neither); the failure modes; and the known
gap that a shared credential has no ceiling below its person's grant.

Marked in-flight: the middleware and lookup exist and are tested, but
the router branch and mint endpoints are not wired, so Flow 1 still
persists a Clerk JWT on a deployed instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tity provider

The `afc_` credential proves who someone is and carries no grant of its own.
This adds the resolver that answers what they may do, and wires the credential
class into the authenticate path so both halves are live.

- `clerk_scope_fetch` reads `public_metadata.scopes` over a bounded,
  authenticated GET. A missing or mistyped claim fails closed to an empty
  capability set; only an unreachable or unparseable provider is an error, so
  a hand-edited metadata object narrows a principal instead of failing a
  request open.
- `clerk_scope_resolver` caches per subject for sixty seconds and serves a
  warm entry for up to fifteen minutes while the provider is unreachable,
  mirroring what the token path already does on a key-set fetch failure. The
  cache holds no authority: it never outlives the process, so there is nothing
  to backfill or reconcile.
- `bearer_or_api_key` routes an `afc_` value to the credential path, ahead of
  the verifier check so a deployment with no identity provider configured
  still resolves one rather than answering 401.
- The middleware registry and the serve host construct both halves. The
  resolver's teardown unwinds after request threads are joined.

A subject the provider no longer knows resolves to no capabilities rather than
to an outage, and is deliberately not cached. That path is the backstop for a
`user.deleted` webhook that never arrived; when it does arrive, the account
teardown cascade removes the credential row and the request never reaches here.

Two test-only registry construction sites gained the new field. The
middleware's tests moved to a sibling file, which the length cap required once
the routing tests landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two different specs both carried the identifier M160_001: the shipped
acceptance-e2e workstream in done/, and this in-flight login-credential
workstream in active/. Reuse is not allowed, and the collision was actively
misleading — the name no longer resolved to one spec.

This workstream takes the next free workstream number. M160_002 was unused
across every branch and all of git history. M136_001's four cross-references
follow it; the three surviving M160_001 mentions all correctly name the
shipped acceptance workstream.

Indy's Aug 11 quote naming the old number stays verbatim, with the renumber
recorded in the context clause beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The resolved-not-granted section covered an unprovisioned subject but not one
the provider has forgotten. That case cannot arise at login — the device flow
needs a live user to approve in the browser — only after a mint, and only
because the credential is durable enough to outlive its person.

Names the ordinary path that closes the window (user.deleted, the teardown,
the cascade on 250), the three ways it stays open, and why an empty capability
set beats ERR_AUTH_UNAVAILABLE for a credential that will never work again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dential's

A machine name rides the partial unique index that makes two live credentials
per machine unrepresentable, and it is displayed back to an operator and
printed into logs. Both the minting endpoint and the command-line client have
to agree on it exactly, so it lives next to the credential's own shape check
rather than being spelled twice (RULE UFS).

Hostnames are the expected input, so dots belong; whitespace and shell
metacharacters do not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three endpoints close the credential's life. Login spends its recovered
session token on the mint and persists what comes back, so the durable
credential rather than a token that dies in a minute is what reaches disk.
The list is how an operator sees which terminals hold one, and therefore how
a shared credential becomes visible. The revoke is what makes logout mean
something on the server rather than only in a local state directory.

Admission is on principal MODE, not on a required scope, and that is the
load-bearing part. A tenant key carries the whole tenant grant, so it already
holds every scope these routes could ask for — no scope could refuse it. It
also sets a non-null user_id from its free-text created_by, so a null check
alone would admit it and let an organisation mint a credential in a person's
name. Only the two classes that ARE a person are admitted.

A principal carries the provider's subject while the row's foreign key wants
core.users(id), so every call resolves one first. That lookup is deliberately
narrower than the bootstrap identity statement, which joins memberships on the
owner role and needs a named workspace: a read-only collaborator satisfies
neither, and a collaborator minting a credential for their own terminal is
exactly the case the resolved-capability model exists to keep working.

Revoking a credential that is already retired and one that was never yours
answer alike, so an identifier cannot be probed for whose it is. UZ-AUTH-024
carries that refusal.

The mint response goes out through the erasing writer: the raw value exists
once, in that body, and nowhere afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wires the three handlers into dispatch and publishes them. Until this, the
handlers existed but nothing could reach them.

Adding the two Route variants broke the exhaustive switches in route_admission
and route_trace — two files this change had no reason to open. That is the
routing design working as intended: a new route cannot ship without an
admission class and a trace policy chosen for it, and a credential-minting
endpoint silently inheriting a default would have been the worse outcome.

Both routes carry no required scope. None could help: a tenant key already
holds every scope this family might name, so the refusal that matters is on
principal mode and lives in the handler beside the ownership check.

`cli-credentials` joins the REST url-shape allowlist, which is where the
checker's own comment says resource policy lives — every collection noun is
declared there with its justification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eping

route_admission and route_trace each listed every route in the system to say
almost nothing. 76 of 80 routes took the default class; 67 of 80 took the
default trace traits. Between them, 283 lines expressed 17 facts.

Both now name only their exceptions and default the rest. The defaults are the
conservative direction on purpose: an unlisted route falls INTO the in-flight
ceiling rather than out of it, and keeps its success spans rather than being
silently suppressed. An omission cannot exempt a new endpoint from
backpressure or cost it telemetry.

The exhaustive arms did buy something real — adding a route failed compilation
until both files were touched, which is how this workstream's own two routes
were caught. What it caught was an omission whose correct answer was the
default anyway, and the price was appending to a 76-item list. Each file now
carries a test that walks the whole Route union and asserts every route is the
default unless it is one of the named exceptions. A route that silently
acquires a runner budget, loses its success spans, or escapes the in-flight
ceiling fails there instead of at compile time.

Deliberately untouched: route_template composes its paths from shared prefix
constants, so flattening it would repeat those twenty times and turn a
one-line prefix change into twenty. route_scopes carries real per-method
logic. route_table carries real per-route data. Those three are structure, not
ceremony.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard refusing a tenant key on the credential routes is the only thing
enforcing Invariant 1, and nothing exercised it. The unit tests asserted SQL
shapes and an enum set — neither would notice the guard being deleted.

Nine tests now call the handlers directly. The tenant-key principal is built
the way tenant_api_key.zig actually builds it: `.mode = .api_key` with a
NON-NULL user_id carrying the free-text created_by. That combination is the
trap, and a handler checking only for a present user_id passes it.

Admission is proved by the code the caller gets instead: a refused principal
answers UZ-AUTH-001 at the guard, an admitted one travels on and answers the
next check. Distinguishing the two is what proves admission without a
datastore.
Comment thread src/agentsfleetd/state/cli_credentials.zig
… writers

Greptile P1: two concurrent logins for the same (user, machine) could both run
the revoke before either insert became visible, so the loser died on the
partial unique index and an otherwise valid login returned an internal mint
error. mint() now takes a transaction-scoped advisory lock keyed on that pair
before the revoke, so the second mint waits and cleanly revokes the first's
fresh row.

CLERK_API_BASE is validated once at boot rather than trusted per request:
https, or plain http on loopback for the offline and boot-drain lanes. A
prefix match alone was a bypass — http://127.0.0.1.attacker.example and
http://localhost@evil.example both start with a loopback prefix while naming a
remote host — so the loopback hostname must now terminate at end-of-string, a
path, or a digits-only port. Every backend call carries the Clerk admin secret
as a bearer header, so a cleartext remote base would ship it in the open; a
set-but-invalid value refuses boot with ERR_STARTUP_ENV_CHECK instead of
booting into an auth outage explained only by per-request warnings.

The base URL and its validation moved to auth/clerk_backend_config.zig, since
RULE FLL caps a Zig file at 350 lines and clerk_backend.zig had reached 355.
clerk_backend re-exports the surface and stays the public API, so no call site
changed.

Codex found the scope resolver's stale-writer guard tied on millisecond
stamps, letting a slower fetch overwrite a fresher entry; the guard is now a
monotonic sequence.

Both loopback test stubs gain a read timeout, an end-of-stream guard,
spawn-failure teardown, and a three-attempt wake that panics loudly rather
than hanging. The divergent-tenant relogin test cleans up deferred. The
command-line snapshot carries apiUrl, with reasonOf, 401, and TOTAL_LEN
pinned.

docs/AUTH.md: the rotation runbook claimed agentsfleetd does not present
CLERK_SECRET_KEY directly. It does, on two live paths, so a rotation that
skips the Fly redeploy fails scope resolution closed once warm cache entries
age out. CLERK_API_BASE is now documented alongside the compromise model it
governs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
indykish added a commit that referenced this pull request Aug 14, 2026
The merged floor sat at 83% while `main` measures 92.40%, leaving nine points
of slack the gate could never catch. Continuous Integration (CI) measures
93.70% on this branch and 98.40% on #600, so 91% clears everywhere it is
graded and still leaves 1.4 points of headroom on `main`.

macOS measures 87.60% on the identical suite — 870 integration tests, same
kcov include/exclude patterns — because the Darwin binary carries Linux-only
branches it never executes and they land in the denominator. `make
test-unit-all` is therefore red on a MacBook and green in CI. Recorded here so
the next reader does not chase it as a defect: production ships Linux, so the
Linux figure is the truthful one, and a platform-conditional floor was
considered and declined.

Also drops two dead variables from `make/test.mk`: `BENCH_MODE` and
`MEMLEAK_TARGET` were each defined once and read nowhere in the repository,
along with the comment describing cross-compile behaviour nothing implements.
`MEMLEAK_CPU` stays — `make/bench.mk` reads it.
docs/AUTH.md gains a question-to-anchor index mirroring the architecture
README, so a reader jumps to the one section that answers them instead of
scrolling 1000 lines. The scope catalogue now names its canon (the enum in
auth/scopes.zig) and links the published user-mintable subset at
docs.agentsfleet.net rather than restating it. The runner token is the fifth
surface, not the sixth — there is no sixth.

The architecture README's AUTH.md row still listed the fleet key as a live
principal; core.fleet_keys retired at M154 §8. Its every-file-has-a-Facts-table
claim is narrowed to the larger topic files, which is what is true on disk.

Audited with no changes needed: README.md, docs/development.md,
docs/AUTH_DEVICE_LOGIN.md, and the architecture topic files — each already
brief, indexed, and linking out to docs.agentsfleet.net where user-facing
coverage exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
indykish and others added 2 commits August 14, 2026 11:11
The parked §4 guard fires only when api_url is stored, so a credential
carrying afc_ with api_url null slips past it and falls to the production
default — the bug §4 exists to kill, surviving in the one case it can still
occur. Dimension 4.6 refuses that record at read instead: isPersistable stays
the token-shape mirror, a record-level isBound joins it inside tokenOf, and
both readers inherit it. Tenant keys exempt, logout still revokes, existing
AUTH_FAIL_MESSAGE names the repair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 4 joins section 5 in the follow-up agent's work order on Indy's Aug 14
call. Recorded with it: section 4 replays section 3's exact delivery and needs
the identifiers that run records, so it is the back half of one live pass
rather than an independent slice — sections 1 through 3 are unrun in the same
way, and the follow-up prompt covers the whole pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
indykish and others added 2 commits August 14, 2026 11:19
Indy's call, Aug 14 2026: the workstream does not stay in active/. It closes
with an honest disposition instead — the harness, provider prerequisites and a
coverage batch delivered; sections 1 through 5 deferred entire, no Dimension
marked DONE and no rubric row graded, because the live pass never ran. Section
4 replays section 3's delivery, so all five move together to the follow-up
agent with the work-order prompt in the PR session notes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 4 lands as one check rather than the two it was specified as. The
refusal it was designed around turned out unreachable: when nothing names a
target the ladder resolves TO the stored deployment, so the two cannot
disagree. What remains is the leg that reached production in silence — no
stored deployment and no named target — which is now refused before anything
is dialled, naming all three exits.

A named --api or AGENTSFLEET_API_URL always wins, on Indy's call: the operator
said where to go, and a wrong pair fails at the server with a 401 that names
the API URL. logout and doctor are exempt from the deployment question while
still requiring a credential — a revoke must reach the deployment that minted
it, and doctor is the diagnostic an operator runs precisely because their
target is wrong.

guardCommand owns the whole pre-action policy, so cli.ts carries no exemption
lists and drops from 354 lines to 330, back under the file cap.

Both specs close with this commit: M160_002 with every section DONE, M136_001
with its live proof deferred and recorded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The replace prompt said only that "an existing credential" was being
overwritten, so switching from one deployment to another read identically to
re-logging into the same one. It now names both when they differ, and says
plainly that the old credential stays LIVE on the deployment that minted it —
this machine loses its identifier on overwrite, so only that deployment's
dashboard can revoke it afterwards.

The prompt reads the record through snapshot rather than the token accessor,
so the token and the deployment it is bound to come from one read. Three test
doubles returned a snapshot whose accessToken disagreed with their own
getAccessToken; the two that feed this path are corrected, and the mirror is
stated in a comment so the next one does not drift.

Adds deployment-binding.integration.test.ts covering the operator journeys
Indy walked: logout forgets the deployment and the next bare login is
production (intended, a full reset); a second login replaces token, deployment
and credential id together, with the orphaned credential pinned as a known gap
in the assertion itself; and a named --api reaches the deployment it names,
where the credential is rejected and the output points at both `agentsfleet
login` and AGENTSFLEET_API_URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant