push_authorization_request lodges the authorization params under a
single-use request_uri; authorize_pushed redeems it into the normal consent
flow. Pushed requests reuse the pending store ({pushed, Rec} keyed by the
request_uri ref — distinct from consent req_ids, so no collision and no new
loop state). The pushed binding (client + redirect + PKCE) is still enforced
at exchange. New tests/par.sx. 217/217.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
notify.sx: reminders + digests as durable flows over an injected transport.
A flow requests delivery (suspend); the host dispatch sends and resumes with
the outcome. At-least-once + idempotent (transport dedups by msg id; replay
logs outcomes). Retry rides suspend/resume with distinct per-attempt tags,
bounded by maxn. Digest delivers a batch with per-message outcomes.
182/182 green. Delivery core is the delivery-on-sx extraction seam.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /.well-known/webfinger?resource=acct:user@host lands in
http_server.erl next to the existing /.well-known/sx-capabilities
arm.
Dispatch chain:
route/2 -> dispatch/4 (matches webfinger path) -> handle_webfinger/1
-> webfinger_for_query/2
-> parse_resource_param/1 (matches "resource=" + collect via
take_until_amp/1)
-> discovery:parse_acct/1
-> webfinger_lookup/3 — host check + kernel actor lookup
-> 200 + discovery:webfinger_body/3 (application/activity+json)
-> 404 on any miss
Cfg surface:
{webfinger_host, Binary} optional; when set the acct's @host
must match exactly. Missing -> any.
{kernel, Atom} optional; when set, the user must be
a known actor in the registered kernel.
Missing -> every user is 'known' (pure
route tests).
route/2 already threads the Req's :query into Cfg as
:request_query (Step 4d), so the handler doesn't need to take
the Req directly.
10/10 in next/tests/webfinger_route.sh:
- GET happy path (no kernel cfg'd) -> 200
- body has subject prefix
- body has href substring
- missing ?resource= -> 404
- garbage 'resource=garbage' -> 404
- kernel cfg: alice 200, ghost 404
- :webfinger_host matches @host -> 200
- :webfinger_host mismatch -> 404
- POST -> 404 (only GET handled)
discovery.sh 12/12 unchanged, http_route.sh 11/11 unchanged.
ticket.sx: checkout-request (events->commerce) + payment-result
(commerce->events) wire shapes — commerce imports the contract. ev/request-
ticket! holds a seat + emits a checkout request; ev/settle-payment! confirms
on :paid, releases on failure/expiry. Idempotent; late paid for a vanished
hold -> :paid-but-no-hold (refund signal). 175/175 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
oauth.sx gains token_exchange(SubjectToken, RequestedScope): a valid access
token is downscoped into a NEW independent grant for the same subject
(subset only, else invalid_scope; inactive subject token → invalid_grant).
The exchanged token's lifecycle is independent of the subject token
(revoking either leaves the other active); exchanges chain. Least-privilege
handoff to downstream services. New tests/exchange.sx. 201/201.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
api.sx gains sessions(Subject) (enumerate a subject's live sessions) and
logout_all(Subject) ("log out everywhere") — revokes and deregisters every
session the subject holds, auditing a logout per session, leaving other
subjects' sessions untouched. Builds on registry.sessions_for. New
tests/session_mgmt.sx. 193/193.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New next/kernel/discovery.erl with the local-side webfinger
primitives per design §13.7:
parse_acct/1(Bin) -> {ok, User, Host} | {error, _}
Accepts <<acct:user@host>> (with prefix) or <<user@host>>
(bare). Host preserves an optional :port suffix. Rejects
empty user/host and missing @.
parse_resource/1 alias for the webfinger ?resource= shape
actor_url_for/2(User, Host)
Synthesises <<http://<host>/actors/<user>>>. TLS / https
is v3, gated on a TLS substrate Blocker.
webfinger_body/3(User, Host, ActorUrl)
Builds the RFC 7033 JSON body:
{"subject":"acct:<user>@<host>",
"links":[{"rel":"self",
"type":"application/activity+json",
"href":"<actor_url>"}]}
Hand-rolled byte concatenation — no JSON BIF on this port.
Substrate gotcha re-confirmed: <<"acct:">> string literals
truncate to one byte on this port. "acct:" is spelled as
<<97,99,99,116,58>> in the implementation.
12/12 in next/tests/discovery.sh covering:
- parse_acct prefixed + bare forms
- host with :port preserved
- reject empty user / missing @ / empty host
- parse_resource alias
- actor_url_for synthesis + port preservation
- webfinger_body prefix shape + byte_size sanity
Step 10b (http_server route GET /.well-known/webfinger) and
Step 10c (peer-actor fetch via Step 5's lookup_or_fetch slot)
layer on top. 10c gates on Blockers #2 (native http-request
primitive missing).
Booking stream gains :hold/:confirm/:release; fold tracks per-actor seat state
(:held/:confirmed). A held seat counts toward capacity so a pending payment
can't be oversold. ev/hold! (capacity-safe), ev/confirm!, ev/release!,
ev/seat-state. Holds race test mirrors the booking race. 144/144 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
delegation.sx makes the loop's central rule concrete: check() introspects
the token first — inactive → {error, unauthenticated} (401), acl never
consulted — and only an authenticated subject's request is delegated to
acl, which returns permit/deny ({error, forbidden} = 403). 401 strictly
precedes 403. acl-on-sx (Datalog) is a different SX guest wired at the
integration layer, so the decider here is a labelled stub (permits when
Action in Scope); swap the pid and the boundary is unchanged. New
tests/delegation.sx. 185/185 — extensions backlog clear.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The identity coordinator now owns an audit ledger and a membership registry
alongside its token table (started with the ledger) and session registry.
login/logout are audited; new ops history/enroll/member_status/member_project
surface the audit and membership axes through the one `identity` door.
Identity proves who and reports membership; acl still decides permission.
Existing api behaviour unchanged. New tests/facade.sx. 177/177.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
device.sx — for input-constrained devices. authorize → {device_code,
user_code}; the human approves/denies out-of-band by user_code; the device
polls by device_code through the §3.5 status machine (authorization_pending
→ access_denied / {ok, Token}). Device code is single-use once a token
issues; approve-after-deny is rejected. Tokens grant-backed via token.sx.
Device-code expiry + slow_down deferred (no wall clock). New
tests/device.sx. 168/168.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
oauth.sx now owns a client registry (loop/6) with register_client and the
client_credentials grant. A confidential client authenticates and gets a
token acting on its own behalf (subject = the client), no refresh token
(§4.4.3). A public client is unauthorized_client; any auth failure (unknown
client or wrong secret) is invalid_client — no client-existence oracle
(§5.2). identity-load-oauth! now pulls its deps. New tests/grants.sx.
158/158.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Booking stream carries :booking/:cancel events; live roster is the folded
replay so cancelling frees a seat and capacity reopens. ev/cancel! (retrying
append-expect), no-op on unbooked, cancelled actor may re-book. Capacity count
is folded roster size. 110/110 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
delivery_worker state shape gains :next_retry proplist alongside
the existing :attempts:
[{peer, _}, {pending, _}, {attempts, [{Cid, N}]},
{next_retry, [{Cid, NextRetryAt}]}, {dead_letter, _},
{dispatch_fn, _}]
New pure-functional exports:
record_failure_pure/3(Cid, Now, State)
Bumps :attempts for Cid. On the 6th failure
(backoff_for returns dead_letter) moves the matching
activity from :pending to :dead_letter and clears the
:next_retry entry. Otherwise sets next_retry to
Now + backoff_for(NewAttempts).
record_success_pure/2(Cid, State)
Clears both :attempts and :next_retry for Cid.
next_due_pure/2(Now, State)
Returns cids whose retry time has passed (insertion
order preserved so the worker drains in FIFO retry
order).
attempts_for/2, next_retry_at/2, dead_letter_list/1
Read-side accessors.
Internal helper move_to_dead_letter/2 + take_by_cid/4 walks
:pending to find the matching activity by cid.
11/11 in next/tests/delivery_retry.sh covering:
- fresh state: 0 attempts / undefined retry / [] dead_letter
- record_failure bumps to 1
- record_failure sets next_retry_at = Now + 30 (slot 1)
- second failure: attempts=2, NextRetryAt = Now + 300 (slot 2)
- record_success clears both
- next_due returns due cids
- next_due empty before due
- 6th failure -> dead-letter; activity out of :pending
- dead-lettered cid removed from :next_retry
- per-cid isolation: success on one doesn't disturb another
delivery_worker.sh 17/17 unchanged (new exports are additive).
Blockers added:
#2 — Native http-request primitive missing in bin/sx_server.ml
(briefing assumed it existed; only http-listen exists).
Belongs to loops/fed-prims. Step 8e wrapper waits for
the native.
#3 — erlang:send_after-style timer primitive missing. Needed
for the real retry loop. Belongs to loops/erlang. 8b-pure
captures the semantics so 8b-timer is a 1-shot wiring
when the primitive lands.
Conformance preserved at 761/761.
clients.sx (RFC 6749 §2) — confidential clients must present the correct
secret at the token endpoint (wrong → invalid_client); public clients are
identified but not authenticated; redirect_uris are pre-registered and
checked by exact-match valid_redirect (§3.1.2.2 + Security BCP). Standalone
module for now; wiring confidential-client auth into oauth exchange is a
follow-up. New tests/clients.sx. 149/149.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The token registry holds a logical clock (advance/now; the substrate has no
wall clock). Grants carry a Ttl; each access token carries an Expires
(Now-at-issue + Ttl, or infinity); introspect returns inactive once Now
reaches it. Refresh mints a fresh short-lived access token — short access
tokens, long refresh tokens. issue/4 and issue_grant/4 default to infinity so
all prior behaviour is unchanged. New tests/expiry.sx. token loop/6. 138/138.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
booking.sx: per-occurrence append-only stream, roster = replay. Booking
decided against an observed (roster, last-seq) snapshot, committed via
persist/append-expect — atomic check+append, no overbooking, no lock.
Explicit last-seat race test: two bookers, one booked, one conflict, roster
capped. Idempotent per actor. 97/97 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each access token now carries its own effective scope (<= the grant's max).
refresh/3 requests a narrower scope; the request must be a subset of the
grant scope, else {error, invalid_scope} and the refresh token is NOT
consumed (client may retry, §5.2). refresh/2 keeps full scope; scope stays
opaque (atom or list) for issue so all prior atom-scope tests are unchanged.
Also files a Blocker: PKCE S256 is blocked on erlang substrate bugs (binary
=:= always true; crypto:hash ignores binary content). token 24/24, 130/130.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
outbox:publish/2 now walks the computed delivery_set and enqueues
the signed activity onto each matching delivery_worker
(registered under the peer-id atom). Missing workers are silently
skipped — lazy worker creation belongs to the kernel manager
later in Step 8.
Gated by Context's {dispatch_deliveries, true} so every M1
outbox caller (and every M2 caller that doesn't yet care about
delivery) stays back-compat: default off.
New helpers in outbox.erl:
dispatch_deliveries/3(Activity, DeliverySet, Context)
gates on Context :dispatch_deliveries flag
enqueue_each/2(Activity, [PeerId | _])
whereis-guarded enqueue per peer
7/7 in next/tests/delivery_dispatch.sh:
- single peer enqueued
- two peers both enqueued (fan-out)
- missing worker silently skipped
- no :dispatch_deliveries flag -> no-op (back-compat)
- two publishes -> FIFO append on the queue
- empty delivery_set -> no-op
outbox_publish.sh 17/17 unchanged; delivery_worker.sh 17/17
unchanged. Conformance preserved at 761/761 from the Step 8a
baseline.
All four phases done. Records an extensions queue (PKCE S256, token TTL,
scope sets/narrowing, client registry, client-credentials/device grants,
acl delegation, state/nonce, unified facade) to keep deepening the engine.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
federation.sx — peer-asserted subjects, advisory and trust-gated. An
assertion is accepted only from an explicitly trusted peer (else
{error, untrusted}) and is flagged {peer_asserted, Peer}, never promoted to
local authority; acl decides what a peer-asserted identity may do. Cross-
instance subject mapping namespaces remote subjects by peer
({federated, Peer, Remote}) so two peers' "alice" never collide, with
optional explicit aliasing. Adds an audit-completeness test. New
tests/federation.sx. All four phases done — 124/124.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
audit.sx is an append-only ledger process. token.sx gains start/1(Audit)
and emits an event on every grant transition (issue, refresh, revoke —
including reuse-triggered revoke); start/0 stays unaudited so existing use
is unchanged (token.sx has no compile-time dep on the audit module, it just
sends to a pid). The ledger answers (identity/audit subject) via
audit/actions/count/all, chronological. In-memory event stream; persist
backing is a later Erlang<->persist bridge, out of scope. 111/111.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>