The ontology names world_create.v4 with its required lens weights and the consumer.v5 state and commit schemas; the consumer API names consumer.v5. Docs only; builds as the previous commit does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
195 lines
10 KiB
Markdown
195 lines
10 KiB
Markdown
# Ghostlight World Consumer API
|
|
|
|
Status: this describes the landed pass-10 consumer ingress — a world's third
|
|
patch author, reaching `AdmitPatch` through one loopback-bound typed document.
|
|
|
|
## Objective
|
|
|
|
Let an external consumer author and observe the exact subjects a Ghostlight
|
|
world has admitted as its mirrors, without becoming part of Ghostlight's
|
|
runtime and without the world learning the consumer's vocabulary. A
|
|
consumer-authored seed and a compiled seed are the same `AdmitPatch`; there is
|
|
no separate seed type, registry, or publication handoff. Delvehold is the
|
|
first consumer; no Delvehold identifier, schema, or policy is core authority
|
|
(`docs/architecture/delvehold-forced-ontology-integration.md` names the
|
|
profile-side binding).
|
|
|
|
## Authority map
|
|
|
|
- **Admission owner:** `require_patch_author` (`world/mod.rs`) decides which
|
|
caller may submit `AdmitPatch` and returns that caller's `PatchGround`;
|
|
`confine_to_ground` decides what a confined ground may write. Both run in
|
|
`reduce`'s `AdmitPatch` arm and again in `apply_effect`'s `PatchAdmitted`
|
|
arm — there is no second confinement function and no way for a patch to
|
|
commit without being re-decided.
|
|
- **Consumer ground:** `PatchGround::Consumer(ConsumerId)` is derived, never
|
|
carried. The set of subjects a consumer may write is
|
|
`{ s | controller_assignments[s] == ExternallyControlled { consumer } }`,
|
|
read from committed state at decision time. A consumer cannot name its own
|
|
scope on the wire, and revoking a binding in a later commit shrinks its
|
|
authority with no ingress change.
|
|
- **Caller minting:** `WorldMailbox::submit_consumer` (`world/mailbox.rs`) is
|
|
the only constructor of `AuthenticatedCaller::verified_system(SystemCapability::Consumer { consumer })`.
|
|
- **Ingress owner:** `world/consumer.rs` owns decoding a document, bounding
|
|
it, authenticating the consumer, one port call, and one receipt
|
|
projection. It owns no world truth, holds no opinion about a patch's
|
|
content, and does not pre-validate — a second reducer here could disagree
|
|
with the kernel's, so structure is decided once, by `resolve_patch`.
|
|
- **Port:** `ConsumerPort` (`world/mailbox.rs`) has one method,
|
|
`submit_consumer`. It cannot read the world: it does not select an answer,
|
|
does not pre-validate, and returns a receipt rather than state.
|
|
|
|
## Typed flow
|
|
|
|
```text
|
|
ConsumerPatchDocument (canonical MessagePack)
|
|
-> ConsumerRegistry: name -> ConsumerId, secret verified against a
|
|
stored SHA-256 digest in constant time
|
|
-> patch::decode_patch, bounded by MAX_PATCH_BYTES / _DECLARATIONS /
|
|
_OPERATIONS / _EVIDENCE
|
|
-> WorldMailbox::submit_consumer(world_id, expected_revision, command_id,
|
|
consumer, answers, patch)
|
|
-> reduce / apply_effect: require_patch_author, the answer rule,
|
|
confine_to_ground
|
|
-> ConsumerReceiptDocument: one atomic verdict — a commit reference or
|
|
the complete mismatch set
|
|
```
|
|
|
|
`command_id` and `world_id` on the receipt are `Option`: a frame that fails to
|
|
decode or names an unregistered consumer never derives a command key or reads
|
|
a world, so the receipt has none to report. The outbound half of this
|
|
contract — a consumer reading its own projection, or receiving an
|
|
attributed proposal — is not part of this pass; see "Not in this pass" below.
|
|
|
|
## Transport
|
|
|
|
`POST /cultnet/world-patch`, declared beside `/cultnet/snapshot` in
|
|
`api_router` (`runtime.rs`). The handler gates loopback and content type
|
|
(`application/msgpack`) exactly as the snapshot route does, then hands the
|
|
body to `world::consumer::admit_document`; everything past those two gates is
|
|
`world/consumer.rs`'s to decide. The body limit is
|
|
`CONSUMER_BODY_LIMIT = patch::MAX_PATCH_BYTES + CONSUMER_ENVELOPE_SLACK` (an
|
|
8 KiB envelope allowance over the 256 KiB patch cap), so the transport guard
|
|
cannot become a second opinion about how big a patch may be.
|
|
|
|
The document and receipt are Ghostlight-owned typed records in canonical
|
|
MessagePack, not CultNet control messages, pinned to two schema constants:
|
|
|
|
```text
|
|
CONSUMER_PATCH_SCHEMA = "ghostlight.consumer_patch.v0"
|
|
CONSUMER_RECEIPT_SCHEMA = "ghostlight.consumer_receipt.v0"
|
|
```
|
|
|
|
`ConsumerPatchDocument` carries `schema`, `world_id`, `consumer` (a
|
|
configured name, lowered to a `ConsumerId` by the registry and never carried
|
|
further), `secret`, `idempotency_key`, `expected_revision`, `answers:
|
|
Option<PatchAnswer>`, and `patch: Vec<u8>` — the encoded `WorldPatch` as a
|
|
nested canonical-MessagePack byte frame rather than an inline value, so
|
|
`MAX_PATCH_BYTES` is checked against the patch itself before any of its items
|
|
deserializes. The outer document is decoded first, is tiny, and is bounded by
|
|
the route's derived limit.
|
|
|
|
`mesh.rs`'s advertisement gains the two schema constants and nothing else:
|
|
this door is loopback-bound, so it is not a remotely reachable surface and
|
|
advertising it as one would claim availability the binary does not have.
|
|
|
|
## Idempotency and staleness
|
|
|
|
`expected_revision` is required; the port submits through
|
|
`submit_authenticated`, not the stamped path, because a caller that cannot
|
|
name the revision it built against cannot be told its batch is stale. The
|
|
idempotency ledger is checked before the revision, so an exact resubmission
|
|
still returns the original receipt (`AlreadyApplied`) even after the world has
|
|
moved; a stale document is `RevisionMismatch { expected, actual }`, and the
|
|
receipt carries the live revision so the consumer knows what to build against
|
|
next. `CommandId::derived` computes the command key from `(world, consumer,
|
|
idempotency_key)`; the same key with a different body is
|
|
`CommandIdConflict`, and nothing commits.
|
|
|
|
There is no receipt-probe request and no separate mailbox `Request` variant
|
|
for it. A consumer's document is byte-identical on retry and its `CommandId`
|
|
is derived, so resubmission is the probe and the ledger answers it directly.
|
|
|
|
## The externally controlled subject
|
|
|
|
A subject declared `NewController::External { consumer }` receives
|
|
`ControllerAssignment::ExternallyControlled { consumer }`. It mints no
|
|
controller ID and no controller mode — both accessors return `Option` for it,
|
|
and every reader fails closed rather than assuming a default. Concretely:
|
|
|
|
- it derives no decision opportunity and mints no turn;
|
|
- it is excluded from `agency_graph` and so from the cover;
|
|
- it is visible in the snapshot as an ordinary subject, related to, targeted,
|
|
and counted like any other, with `controller_id: None` and
|
|
`controller_mode: None`;
|
|
- it may hold no affordance — declaring it with a non-empty affordance grant
|
|
set is `Mismatch::ControllerGrantMismatch`, and the pairing is checked both
|
|
ways (an ordinary subject with an empty grant set is the same mismatch);
|
|
- its components change through exactly two callers: its own consumer, and
|
|
the world owner, who is unconfined and always could write it — that is the
|
|
owner's standing authority, not a leak in the consumer boundary.
|
|
|
|
## Confinement
|
|
|
|
A consumer writes only the subjects bound to it and operations grounded on
|
|
those subjects (`confine_to_ground`, the `PatchGround::Consumer` arm):
|
|
|
|
- a declared subject is admitted only if its controller names the same
|
|
consumer; position is unconfined;
|
|
- a declared place, a declared route, `Relocate`, `OpenRoute`, `CloseRoute`,
|
|
and `AlterCost` are always refused — they carry a place or a route, and a
|
|
consumer's ground names no place;
|
|
- `Witness` is always refused, for the same reason: its ground is the named
|
|
place, never the acting subject, and a consumer's ground names no place.
|
|
A consumer tells what its mirror knows through `AcquireKnowledge` on its own
|
|
bound subject; it cannot cause a place-wide witnessed event;
|
|
- a `Claimed { by }` fact must name a bound subject; a `Canonical` fact
|
|
declaration is refused outright — a consumer's evidence buys an `Admit`
|
|
into its own custody and a `Claimed` fact, never a canonization;
|
|
- a channel's reach and controller must all be bound subjects;
|
|
- every operation's ground subjects must be bound, and its place and route
|
|
lists must be empty — `Transfer` therefore requires both endpoints bound,
|
|
so a consumer cannot push custody into a Ghostlight subject; `Admit` and
|
|
`Consume` work inside its own custody.
|
|
|
|
In Active phase a consumer patch that declares answers nothing except a
|
|
`CausalBoundary::MissingStructure { subject }` derived on one of its own
|
|
mirrors — the world noticing that a subject it can see has structure it
|
|
cannot account for. Every other Active declaration is refused by the answer
|
|
rule plus this confinement, not by a separate phase gate: a consumer cannot
|
|
answer a boundary on a foreign subject (not covered), cannot answer a
|
|
`Deficit` row (deficits are jurisdictional; a consumer holds no
|
|
jurisdiction), and cannot declare without an answer. A component-only batch —
|
|
the overwhelming majority of consumer traffic — answers nothing and commits
|
|
through the same `require_answer` path as any other author.
|
|
|
|
## Operator configuration
|
|
|
|
`ConsumerRegistry` is loaded once at startup from the file named by
|
|
`GHOSTLIGHT_CONSUMER_CREDENTIALS`: one `name = <64 hex digits>` line per
|
|
consumer (the SHA-256 of that consumer's shared secret), blank lines and `#`
|
|
comments ignored. A missing file means no consumers, and every document is
|
|
`Unauthenticated`, the fail-closed default that lets this route exist before
|
|
an operator configures anything. A file that exists but does not parse is
|
|
fatal at startup, so a mistyped credential can never read as "no consumers". The registry holds only digests; the
|
|
plaintext secret never enters the process image beyond the hash and never
|
|
enters a log, a receipt, or the journal.
|
|
|
|
## Deployment
|
|
|
|
The consumer capability's tag is part of the commit digest and the
|
|
externally controlled assignment is part of the state shape, so both bump the
|
|
schema. World lens weights later entered the state and `SetLensWeights`
|
|
the commit, so the live schemas are state `ghostlight.world_state.consumer.v5`
|
|
(state-schema generation `world-v3`) and commit
|
|
`ghostlight.world_commit.consumer.v5`. A store written under an earlier schema
|
|
is refused, not migrated.
|
|
|
|
## Not in this pass
|
|
|
|
- The outbound consumer response — a consumer reading its own projection, or
|
|
receiving an attributed proposal about a Ghostlight subject acting toward
|
|
its mirror. This is the next seam; building it prematurely would grow a
|
|
second projection owner ahead of its use.
|
|
- A CultMesh authority lease for a non-loopback consumer. Until that exists,
|
|
this route is loopback-only and offers a consumer no remote door.
|