Skip to content

Latest commit

 

History

History
196 lines (162 loc) · 7.94 KB

File metadata and controls

196 lines (162 loc) · 7.94 KB

Transactional Rollback-Anchor Service

octoscript_storage::rollback_anchor_service defines a bounded host-only protocol for a separately trusted transactional service. It is intended for deployments where the service retains rollback-resistant per-record state outside the rollback domain of a local SQLite payload file.

The protocol is not a Octoscript capability, tool, or general HTTP API. Storage record keys, service URLs, and authentication values remain host configuration. No generated source can select an endpoint, issue a request, inspect anchor state, or obtain a token.

Client Setup

The optional HTTPS transport is fixed to one complete host-selected endpoint. It requires HTTPS, disables environment proxies and redirects, sends only bounded JSON POSTs, accepts only bounded JSON 2xx responses, and does not expose its endpoint or token through Debug.

[dependencies]
octoscript-storage = { path = "../octoscript-storage", features = ["sqlite", "https-rollback-anchor"] }
use octoscript_storage::{
    https_rollback_anchor::{
        HttpsRollbackAnchorAuthorization, HttpsRollbackAnchorTransport,
    },
    rollback_anchor_service::TrustedServiceRollbackAnchor,
    sqlite::AnchoredSqliteStore,
};

let authorization = HttpsRollbackAnchorAuthorization::bearer(host_provisioned_bearer_token)?;
let transport = HttpsRollbackAnchorTransport::new(
    "https://anchor.example.invalid/v1/octoscript-anchor",
    Some(authorization),
)?;
let anchor = TrustedServiceRollbackAnchor::new(transport);
let backend = AnchoredSqliteStore::open("/host-owned/octoscript.sqlite", anchor)?;
# let _ = backend;
# Ok::<(), Box<dyn std::error::Error>>(())

The bearer token must come from trusted host provisioning, such as an app enrollment flow or a native credential backend. It is not stored in an authenticated record and is not a Octoscript secret API. A host can omit bearer authentication when its independently configured transport authentication is sufficient.

The optional HTTPS feature uses Rustls' ring provider. Android builds require the matching Android NDK compiler to be configured for Cargo; the feature is not a pure-Rust cross-compile dependency.

The client does not pin DNS results, contain OS egress, attest the remote service, or make a service correct merely because TLS succeeds. HTTPS provides transport authentication for the configured host name; the service's durable state and deployment trust model remain separate requirements.

Service Core

RollbackAnchorService<A> is an embeddable server-side dispatcher for the same wire protocol. It accepts one complete request body, rejects malformed, oversized, noncanonical, or unsupported-version requests, calls the supplied RollbackAnchor, and returns only a bounded canonical response. Its errors redact request bytes, storage keys, states, and backend diagnostics.

It is deliberately not an HTTP/RPC listener, authentication layer, cache policy, concurrency primitive, or durable backend. A deployment must cap the body before buffering it, serialize access as required by the chosen backend, and translate handler failures to generic non-success responses without exposing backend diagnostics. Successful responses must not be cached. VolatileRollbackAnchor is suitable for tests and local development only; wrapping it in this dispatcher does not create durability or rollback protection.

Authorization Gate

AuthorizedRollbackAnchorService<A, Z> wraps the dispatcher with a host-owned RollbackAnchorServiceRequestAuthorizer. It first decodes the bounded canonical request, then asks Z to authorize the exact operation and record key before it can call the anchor. An authorizer error or denial is a generic Unauthorized result and never reaches the backend.

FixedRollbackAnchorServiceAuthorizer is the built-in static capability policy. It accepts at most 128 configured caller IDs and 1,024 exact (caller, operation, record) grants. A load grant does not grant compare_and_swap; there are no namespace wildcards or implicit write rights. The service's authentication middleware must map a successfully authenticated principal to RollbackAnchorServiceCallerId before invoking handle_authenticated_request. Caller IDs are host-only opaque identifiers, not request fields or bearer credentials.

Dynamic-tenancy services can provide their own authorizer, but it must fail closed and must derive the supplied caller from already authenticated transport or session state. The gate does not authenticate a caller, provide replay protection, select a TLS route, serialize concurrent callers, or make the anchor durable.

For a network deployment, terminate TLS and enforce the exact service route in trusted server configuration, then place a real atomic, rollback-resistant CAS authority behind the dispatcher. A globally authenticated service still needs an explicit per-tenant key-authorization policy when more than one tenant can address it; the dispatcher cannot infer that policy from a record key.

Wire Protocol

Every request and response is a UTF-8 JSON object no larger than 4 KiB. The protocol version is currently 1. Revisions and fences are canonical decimal strings rather than JSON numbers, so every u64 value survives JavaScript and other JSON implementations without precision loss:

  • "0" is the only zero spelling.
  • Nonzero values contain ASCII decimal digits with no leading zero.
  • record_commitment is null exactly when revision_floor is "0".
  • A non-null commitment is the unpadded URL-safe Base64 encoding of exactly 32 bytes.

The host client sends either:

{
  "version": 1,
  "operation": "load",
  "key": {"namespace": "workflow-ledger", "name": "release-42"}
}

or:

{
  "version": 1,
  "operation": "compare_and_swap",
  "key": {"namespace": "workflow-ledger", "name": "release-42"},
  "expected": {
    "revision_floor": "1",
    "record_commitment": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
    "fencing_token": "4"
  },
  "replacement": {
    "revision_floor": "2",
    "record_commitment": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE",
    "fencing_token": "5"
  }
}

A load response must be:

{
  "version": 1,
  "outcome": "state",
  "state": {
    "revision_floor": "2",
    "record_commitment": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE",
    "fencing_token": "5"
  }
}

A compare-and-swap response is either {"version":1,"outcome":"stored"} or {"version":1,"outcome":"conflict","actual":{...}}, where actual is a complete state object. The client rejects a response shape intended for the wrong request.

Service Requirements

For every exact (namespace, name) key, the service must:

  1. Persist the complete state durably outside the rollback domain of the local payload store.
  2. Atomically replace expected only when it is current, returning the exact observed state on conflict.
  3. Reject a lower revision, a lower fencing token, or a changed commitment at the same revision.
  4. Return only after a successful state transition is durable and rollback-resistant through its own transactional, hardware, or equivalent authority.
  5. Disable stale-response caching for the endpoint and treat failed or ambiguous requests as indeterminate rather than as a successful commit.

TrustedServiceRollbackAnchor validates outgoing transitions and keeps a process-local observed-state floor to detect a regressing response during one process lifetime. That cache is defense in depth only: it is lost on restart and cannot replace the service's durable monotonic authority.

The client also refuses to send a compare-and-swap whose expected state regresses a state already observed in this process. A host that has observed a newer state must reload or reconcile before it retries; it must not blindly retry an older expectation.

The client redacts service transport errors and response bytes. Hosts should retain service diagnostics in their own protected observability system rather than exposing them to worker or Octoscript diagnostics.