Ghostlight/docs/architecture/ghostlight-world-consumer-api.md
2026-10-22 23:56:56 +02:00

11 KiB

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 (crates/ghostlight/src/lib.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 (crates/ghostlight/src/mailbox.rs) is the only constructor of AuthenticatedCaller::verified_system(SystemCapability::Consumer { consumer }).
  • Ingress owner: crates/ghostlight/src/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 (crates/ghostlight/src/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

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

Status: the HTTP door below lived in the Dungeon daemon's api_router and is parked with it at tag parked/ghostlight-eve-surface; no process on main serves it. ghostlight::admit_document and encode_receipt are the contract a host serves.

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 ghostlight::admit_document; everything past those two gates is 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:

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;
  • Retire, GrantAffordance, RevokeAffordance, and Mint are always refused (ResolvedOps that are authority over standing, not structure over ground);
  • a Claimed { by } fact must name a bound subject; a Ruled fact is refused outright, and so is a Canonical fact declaration — 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.v6 (state-schema generation world-v3) and commit ghostlight.world_commit.consumer.v6. 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.