Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Maskura docs

Maskura is an S3-compatible gateway that runs your WebAssembly plugins over every object in transit. Point any S3 SDK, CLI, or tool at Maskura; each object passes through your plugin pipeline — filter, redact, encrypt, convert, validate, route — and the result is forwarded to any S3-compatible storage backend.

The quickest way to see it working is the README’s Docker quickstart: run the published Maskura image with AUTH_DISABLED=true and a durable local volume, open the demo dashboard, and push a file through Maskura’s own S3-compatible API. No MinIO service is involved in this path. This site is the deeper documentation:

  • Plugins — write, load, and compose your own Wasm filters.
  • MinIO-compatible Docker image — run Maskura as a zero-config drop-in replacement for docker run minio/minio.
  • Run the claims — black-box S3, plugin, and Python encryption proofs with explicit assertions and limits.
  • Avro gate — the typed Avro OCF processing path.
  • Binary adapters — schema-aware binary reductors for typed formats.
  • MCP — the local stdio MCP server for agent clients.
  • End-to-end suite — the no-secrets local e2e harness.
  • Security — the security model, trust boundaries, and deployment responsibilities.
  • Architecture Decision Records — the decisions behind the design.

Open source and hosted boundaries

Maskura has an Apache-2.0 data plane and a separately operated hosted service. This page states the boundary directly so evaluating the open-source project does not require reverse-engineering the commercial model.

In this Apache-2.0 repository

The public repository contains:

  • the self-hosted S3-compatible gateway and streaming object data plane;
  • the Wasmtime runtime, WIT contracts, built-in filters, and local plugin lifecycle;
  • supported storage adapters and routing behavior;
  • the maskura CLI and local maskura-mcp stdio server;
  • the OpenAPI schema and generated Python and TypeScript clients;
  • local file, in-memory, and Postgres-backed credential repositories; and
  • tests, benchmarks, security documentation, and architecture decisions for the public engine.

The local Docker quickstart runs without a Maskura account, cloud account, or database. Self-hosted operators choose their own storage, deployment, identity, and operational controls.

In hosted Maskura

The hosted service operates the public gateway engine with separate services for multi-tenant identity and workspace administration, billing and usage accounting, hosted policy management, managed operations, and Maskura Store. Those hosted control-plane and operational services are not part of this Apache-2.0 repository.

The hosted browser preview requires login. This is an abuse-control boundary for a public processing endpoint, not a requirement of the self-hosted engine.

Data boundary

With a self-hosted gateway, Maskura’s hosted service is not in the request path. The operator controls the gateway and destination storage.

With hosted Maskura Gateway, object bytes pass through the operated gateway and then go to the workspace’s configured storage. With Maskura Store, the hosted service also provides the destination storage. The selected pipeline determines whether supported fields are passed through, redacted, or encrypted; Maskura does not claim that every object is automatically confidential.

In either deployment, the gateway operator is trusted during processing. Configuration signatures authenticate artifacts; they do not independently prove which code or policy ran for a read/write. Maskura does not currently provide end-to-end execution attestation, and client-held decryption keys do not prevent the gateway from seeing plaintext while transforming it.

See Security for trust boundaries, fail-closed behavior, feature gates, and operator responsibilities.

Demo

The recording shows the same PII file written through different plugin pipelines. It is useful for seeing the interaction, but it is not the evidence: the old recording predates the current credential flow and cannot assert what was stored.

Run the current, assertion-backed version instead:

just proof

See Run the claims for each check and its limits.

Maskura as a MinIO-compatible Docker image

MinIO retired its free Docker distribution. If you previously used docker run minio/minio for local S3 storage, Maskura offers a similar setup: a single S3-compatible container with no required environment variables or external services.

See ADR 0019 for the design and limits of the local appliance.

Quickstart

docker run -d --name maskura \
  -p 127.0.0.1:9000:9000 \
  -v maskura-data:/data \
  ghcr.io/231self/maskura/maskura:latest

The first start generates a root SigV4 credential, persists it inside the volume, and prints it once:

docker logs maskura | grep 'generated local root credentials'
#  generated local root credentials access_key=maskura_… secret_key=maskura_secret_…

Point any S3 client at http://127.0.0.1:9000. Maskura creates a default maskura bucket, so you can upload without running mb first.

Overriding the root credential

Set both variables together to use your own root credential instead of a generated one (both are required):

docker run -d --name maskura \
  -p 127.0.0.1:9000:9000 \
  -v maskura-data:/data \
  -e MASKURA_ROOT_USER=my-root \
  -e MASKURA_ROOT_PASSWORD=my-secret \
  ghcr.io/231self/maskura/maskura:latest

aws CLI

export AWS_ACCESS_KEY_ID=maskura_…
export AWS_SECRET_ACCESS_KEY=maskura_secret_…
export AWS_DEFAULT_REGION=us-east-1

aws --endpoint-url http://127.0.0.1:9000 s3 ls                 # ListBuckets
aws --endpoint-url http://127.0.0.1:9000 s3 cp README.md s3://maskura/
aws --endpoint-url http://127.0.0.1:9000 s3 cp s3://maskura/README.md ./roundtrip

The endpoint also accepts the header form (x-maskura-access-key / x-maskura-secret-key) for tools that do not implement SigV4.

Supported operations

See the S3 data-plane compatibility table for the per-operation status and the test that covers each. In short, the appliance implements bucket create/delete (appliance only), object PutObject/GetObject/HeadObject/DeleteObject, ListObjects v1 and v2, ListBuckets, and durable staged multipart upload.

Unsupported operations

S3 features outside the data-plane verb list return 501 NotImplemented rather than being handled as a different operation: versioning, ACLs, bucket/object tagging, policies, lifecycle, encryption config, website, CORS, replication, object-lock, legal-hold, notifications, logging, request-payment, and CopyObject/UploadPartCopy (x-amz-copy-source). The full per-verb status is in the compatibility table.

Limits

The appliance is a single-node, byte-preserving local store, not a claim of full AWS S3 or historical MinIO feature parity. With no plugins configured (the default), uploaded bytes are stored and returned unchanged, including arbitrary binary objects. The Wasm transform pipeline (PII redaction, encryption) remains opt-in and applies to record-oriented formats (text, JSON, JSONL, CSV, TSV).

S3 data-plane compatibility

Maskura’s zero-config Docker image is a single-node S3-compatible endpoint intended as a local MinIO replacement. This page lists the S3 data-plane operations it implements and points at the test that covers each one.

It intentionally covers data-plane verbs only. Bucket subresources — versioning, ACLs, policies, tagging, lifecycle, notifications, object lock, and the rest — are out of scope and handled fail-closed; see Limits and ADR 0019.

Every Supported and Local appliance only row links to a test in this repository, and the How to run the evidence section lists the exact commands. Treat this table as derived output: if the dispatch in crates/gateway/src/server.rs or a cited test changes, update this page in the same change.

Status legend

StatusMeaning
SupportedImplemented on the local appliance.
Local appliance onlyImplemented for the durable local FileStore; on a configured workspace or backend the request returns 403 AccessDenied.
Not implementedRecognized and rejected before dispatch with 501 NotImplemented, without touching state.
Not routedNo route exists for the request; it does not reach a handler.

Operations

OperationRequestStatusEvidenceNotes
ListBucketsGET /Supporteds3_frontdoor_test::list_buckets_at_rootBrowsers get the dashboard on the same path; S3 clients get the XML.
CreateBucketPUT /{bucket}Local appliance onlys3_frontdoor_test::filestore_conformance_bucket_object_lifecycle_survives_restart, s3_frontdoor_test::create_bucket_is_rejectedExisting bucket → BucketAlreadyExists; a configured backend → 403 AccessDenied.
DeleteBucketDELETE /{bucket}Local appliance onlys3_frontdoor_test::filestore_conformance_bucket_object_lifecycle_survives_restart, file_store::delete_and_bucket_lifecycle_require_empty_bucketEmpty bucket → 204; non-empty → 409 BucketNotEmpty; missing → NoSuchBucket.
HeadBucketHEAD /{bucket}Local appliance onlys3_frontdoor_test::filestore_list_and_head_on_missing_bucket_return_no_such_bucketServed by the GET /{bucket} route (body stripped). Missing bucket → 404 NoSuchBucket, existing → 200.
ListObjects (v1)GET /{bucket}Supporteds3_frontdoor_test::list_objects_returns_keys_and_prefixes, s3_frontdoor_test::filestore_list_and_head_on_missing_bucket_return_no_such_bucketPrefix, delimiter, and pagination. Missing bucket → NoSuchBucket.
ListObjectsV2GET /{bucket}?list-type=2Supporteds3_frontdoor_test::memory_list_continuations_are_opaque_bound_and_tamper_resistant, s3_frontdoor_test::filestore_list_and_head_on_missing_bucket_return_no_such_bucketOpaque, bound continuation tokens; continuation-token requires list-type=2. Missing bucket → NoSuchBucket.
PutObjectPUT /{bucket}/{key}Supporteds3_frontdoor_test::put_get_roundtrip_filters_pii, s3_frontdoor_test::unmodified_rust_sdk_default_put_is_acceptedWith no plugins configured, bytes are stored unchanged.
GetObjectGET /{bucket}/{key}Supporteds3_frontdoor_test::streaming_memory_get_preserves_range_and_head_metadata, s3_frontdoor_test::conditional_get_and_head_preserve_object_identity_without_a_bodySupports Range and conditional request headers.
HeadObjectHEAD /{bucket}/{key}Supporteds3_frontdoor_test::head_and_delete_remain_availableMetadata only; no body.
DeleteObjectDELETE /{bucket}/{key}Supporteds3_frontdoor_test::head_and_delete_remain_availableMissing key → 404 NoSuchKey.
CreateMultipartUploadPOST /{bucket}/{key}?uploadsSupporteds3_multipart_conformance::sdk_multipart_lifecycle_completes_over_real_tcpStaged and durable; requires the local staged multipart store (enabled on the appliance).
UploadPartPUT /{bucket}/{key}?partNumber&uploadIdSupporteds3_multipart_conformance::sdk_multipart_lifecycle_completes_over_real_tcp, local_filesystem_multipart::local_filesystem_multipart_lifecycle_over_httpParts persist across restart.
CompleteMultipartUploadPOST /{bucket}/{key}?uploadIdSupporteds3_multipart_conformance::http_conformance_completion_errors_pagination_helpers_and_replayCompletion replaces any existing single-PUT object atomically.
AbortMultipartUploadDELETE /{bucket}/{key}?uploadIdSupporteds3_multipart_conformance::sdk_multipart_abort_list_uploads_and_no_such_uploadUnknown upload → NoSuchUpload.
ListPartsGET /{bucket}/{key}?uploadIdSupportedlocal_filesystem_multipart::local_filesystem_multipart_lifecycle_over_httpFull part listing with pagination fields.
ListMultipartUploadsGET /{bucket}?uploadsSupporteds3_multipart_conformance::sdk_multipart_abort_list_uploads_and_no_such_uploadIn-progress uploads for the bucket.
CopyObjectPUT /{bucket}/{key} + x-amz-copy-sourceNot implementeds3_protocol::s3_not_implemented_returns_501, crates/gateway/src/server.rs (x-amz-copy-source pre-dispatch gate)Rejected before the body is polled.
UploadPartCopyPUT ?partNumber&uploadId + x-amz-copy-sourceNot implementedcrates/gateway/src/server.rs (s3_upload_part copy-source check)Rejected before any part is stored.
DeleteObjects (batch)POST /{bucket}?deleteNot routedcrates/gateway/src/server.rs (no POST /{bucket} route; delete is a gated subresource)Returns 405 Method Not Allowed.
SelectObjectContentPOST /{bucket}/{key}?selectNot implementeds3_protocol::s3_not_implemented_returns_501, crates/gateway/src/server.rs (select subresource gate)Rejected before dispatch.

How to run the evidence

The Rust suites that back the table:

cargo test -p maskura-gateway --test s3_frontdoor_test
cargo test -p maskura-gateway --test s3_multipart_conformance
cargo test -p maskura-gateway --test local_filesystem_multipart
cargo test -p maskura-gateway --test s3_protocol
cargo test -p maskura-gateway file_store::

Unmodified AWS CLI and boto3 interoperability (requires aws and boto3 on PATH):

just interop

The published container is proven black-box (including the AWS CLI round trip) by just proof; see Run the claims.

Limits

The appliance is a single-node, byte-preserving local store, not a claim of full AWS S3 or historical MinIO feature parity. With no plugins configured (the default), uploaded bytes are stored and returned unchanged, including arbitrary binary objects. The Wasm transform pipeline (PII redaction, encryption) remains opt-in and applies to record-oriented formats (text, JSON, JSONL, CSV, TSV).

Bucket subresources that are not data-plane verbs — versioning, ACLs, bucket and object tagging, policies, lifecycle, encryption config, website, CORS, replication, object-lock, legal-hold, notifications, logging, request-payment — are recognized and rejected with 501 NotImplemented before method/path dispatch, so they can never be misrouted into a destructive operation. See ADR 0019.

Run the claims

This is the shortest path from a Maskura claim to observable behavior. The proof harness treats the published container as a black box and exits nonzero if an assertion fails. It does not scrape gateway logs for credentials or ask you to trust a screenshot.

just proof

Requirements: Docker, curl, Python 3, the AWS CLI, and preferably uv. Without uv, install the Python SDK’s declared dependencies first. The run uses an isolated container, Docker volume, API key, and object keys, then removes the container and volume.

The default is the current published image. Pin a release when you want the same bytes on a later run:

MASKURA_PROOF_IMAGE=ghcr.io/231self/maskura/maskura:v0.7.29 just proof

The harness prints the pulled image digest before it starts its assertions. It boots Maskura with transformed reads disabled, so each GET returns the raw write-path result rather than applying a second transform on read.

What it proves

ClaimCommandAssertion
Maskura is an S3-compatible local endpointjust proof redactionAn unmodified AWS CLI writes and reads an object through Maskura. The raw read-back contains the email/card redaction markers and neither plaintext value.
A Wasm component can change the pipeline at runtimejust proof pluginThe harness extracts email-detect.component.wasm from the exact image under test, disables the bundled pipeline, imports that component over the live admin API, and writes the same email/card input. Only the email changes, showing that the imported component—not a hard-coded gateway transform—produced the result.
The Python client implements the shipped hybrid envelopejust proof pythonThe SDK creates an X25519 + ML-KEM-768 keypair, confirms only its public key is attached, writes PII, confirms the raw read-back contains hybrid envelopes and no plaintext, then recovers the exact input with the client-held private key.

The Python step is deliberately a normal script rather than test-only code: examples/python-hybrid-roundtrip.py. The orchestrator is examples/prove-maskura.sh.

What it does not prove

Here, “proof” means a reproducible integration check of the tested image and inputs, not a cryptographic computation proof or remote execution attestation. Passing checks do not establish which code a hosted service ran for a customer’s operation, that it did not copy plaintext, or that every operation used an approved policy. Build-provenance attestations likewise identify release artifacts, not a running service’s per-operation behavior.

This suite is narrow on purpose. It does not establish formal cryptographic correctness, exhaustive S3 conformance, hosted tenant isolation, or the security of your deployment configuration. Those claims have separate evidence:

Maskura v0.6.0

This release makes the local, inspectable Maskura path a first-class release surface.

Highlights

  • S3-compatible local storage: run one container with a durable volume—no MinIO, database, cloud account, or sign-up required. Local staged multipart uploads include encrypted artifacts, a durable journal, fencing, and restart recovery.
  • Bring your own Wasm pipeline: import, enable, disable, and reorder components through the live API without rebuilding the gateway. The built-in PII filter remains available as the default path.
  • Recoverable encrypted PII: the CLI, Python client, and TypeScript client support the shipped X25519 + ML-KEM-768 hybrid envelope while the private key stays with the client.
  • Executable evidence: just proof checks S3 interoperability and redaction, runtime Wasm import, and a Python encrypted round trip against the published image.
  • Hardened delivery: dependency audits, CodeQL, pinned build inputs, checksums, an SPDX SBOM, and build-provenance attestations are enforced by the release path.

Artifact coverage

  • Multi-architecture container image: Linux AMD64 and Linux ARM64.
  • Native CLI and MCP binaries: Linux AMD64, Linux ARM64, and Apple Silicon.
  • Canonical Python and TypeScript SDK archives, with legacy-name compatibility archives during the rename transition.

Verify it

docker buildx imagetools inspect ghcr.io/231self/maskura/maskura:v0.6.0
gh release download v0.6.0 --repo 231self/maskura
sha256sum --check SHA256SUMS --ignore-missing
gh attestation verify --repo 231self/maskura ./maskura-linux-amd64
cat IMAGE_DIGESTS

MASKURA_PROOF_IMAGE=ghcr.io/231self/maskura/maskura:v0.6.0 just proof

The threat model, guarantees, and explicit non-goals remain documented in docs/security.md and docs/proofs.md.

Maskura plugins

Plugins are WebAssembly components that transform object data in an ordered pipeline. A plugin can filter, redact, encrypt, validate, normalize, convert, or perform another deterministic byte transformation. The output of one plugin is the input of the next.

Contract ownership

The public maskura-plugin-sdk crate owns the canonical WIT contract and thin Rust bindings. Both the gateway host and every in-tree transformer consume that same source:

package maskura:plugin@0.1.0;
world transformer { /* begin, transform, finish */ }

Purpose is metadata, not ABI. A plugin under plugins/crypto/ and one under plugins/filters/ implement the same transformer world, so new capability families do not require new protocols.

FunctionCalledPurpose
begin(context)once per objectReceive format, operation, policy, bounded config, and explicitly granted sensitive inputs
transform(payload)once per recordReturn emit(bytes), drop, or reject(reason)
finish()once at the endFlush bounded trailing output

The sandbox provides bounded memory, tables, stack, time, and fuel. It does not inherit the host filesystem, environment, network, stdout, or stderr.

Write a Rust plugin

[lib]
crate-type = ["cdylib"]

[dependencies]
maskura-plugin-sdk = { git = "https://github.com/231self/maskura", tag = "v0.7.13" }
#![allow(unused)]
fn main() {
use maskura_plugin_sdk::{Context, Decision, Guest, export_plugin};

struct Normalize;

impl Guest for Normalize {
    fn begin(_context: Context) -> Result<(), String> {
        Ok(())
    }

    fn transform(payload: Vec<u8>) -> Result<Decision, String> {
        Ok(Decision::Emit(payload))
    }

    fn finish() -> Result<Vec<u8>, String> {
        Ok(Vec::new())
    }
}

export_plugin!(Normalize);
}

Build it as a WASI reactor and lift it to a component with the pinned adapter, as demonstrated by scripts/build-plugins.sh. The minimal in-tree example is plugins/transforms/noop.

Official plugin layout

Maskura keeps one contribution surface until external contribution volume justifies another repository:

plugins/
  filters/       content selection and redaction
  crypto/        encryption and deterministic protection
  transforms/    general-purpose byte transformations
  shared/        internal libraries, not loadable components

Each loadable plugin has a plugin.toml with its stable ID, category, status, world, version, and requested capabilities. official means maintained and released by Maskura; experimental means the API or behavior may still change. The label does not weaken sandboxing or validation.

Do not add an empty category to advertise a roadmap. Add a category when its first useful plugin lands. If outside contributions eventually need independent ownership or release cadence, the SDK keeps a future community repository from forking the ABI.

Load a component

Self-hosted gateways support runtime import without rebuilding the server:

maskura plugin upload my-plugin.component.wasm
maskura plugin list
maskura plugin reorder my-plugin pii-default
maskura plugin disable <id>
maskura plugin delete <id>

Or auto-load every .wasm component in a directory at startup:

MASKURA_PLUGINS_DIR=./components ./target/debug/maskura-gateway

A component may have an adjacent metadata sidecar, for example email-detect.component.wasm and email-detect.plugin.toml. The sidecar’s version and world fields become its signed-pipeline catalog identity. A legacy component without a sidecar defaults to version 0.1.0 and maskura:plugin/transformer@0.1.0. Name/version references rely on the component source not reusing a version for different bytes. Use the component’s 64-hex SHA-256 digest as the pipeline plugin name when the policy must remain byte-for-byte pinned across restarts.

MASKURA_DEFAULT_PLUGIN selects the initial component; local images use the official pii-default artifact. just proof plugin verifies runtime import against the published container and observes a transformation produced only by the imported component.

Hosted workspaces persist immutable plugin versions, installations, ordered pipeline revisions, assignments, grants, and validation results. Uploads use the same world identifier:

maskura hosted upload ./my-plugin.component.wasm \
  --slug my-plugin --display-name "My Plugin" --version 1.0.0 \
  --world maskura:plugin/transformer@0.1.0 --wit-version 0.1.0

Only workspace owners mutate hosted plugin state. Sensitive context is passed only after an explicit capability grant. Empty pipelines require explicit pass-through, and failed read transforms never disclose unprocessed fallback data.

Declarative pipelines (self-hosted)

Self-hosted gateways can define their processing chains in a signed TOML file instead of relying on catalog order. Configure [wasm] pipelines_file and pipeline_trust_roots (or their environment overrides); author and sign with:

maskura pipelines init-key --out maskura-pipeline.key
maskura pipelines sign    --key maskura-pipeline.key pipelines.toml
maskura pipelines verify  --trust-roots "acme=<hex-public-key>" pipelines.toml
maskura pipelines check   pipelines.toml

A file carries dedicated, ordered write and read chains per scope — default, buckets.<bucket>, workspaces.<id>, and workspaces.<id>.buckets.<bucket> — and the most specific assignment wins. See ADR 0021 and the configuration reference. All references are validated before startup. Components uploaded after startup require a restart before a signed file can reference them; hot reload is intentionally deferred.

Typed binary formats can additionally use the binary-reductor world from the same WIT package. See Binary adapters.

Encryption

This page explains how Maskura’s envelope encryption works, from the key you hand the gateway to the bytes that leave your writer. It is the long-form companion to the post-quantum feature page and ADR 0011.

The model

Encryption is envelope encryption with a fresh data key per field:

  1. The gateway generates a random 256-bit data encryption key (DEK) and encrypts the field with AES-256-GCM.
  2. The DEK is then key-encapsulated to your public key, so only the holder of the matching private key can recover it.
  3. Both the ciphertext and the encapsulated DEK are written to storage.

Maskura does see the plaintext field transiently while the selected transform runs inside the per-session Wasm sandbox. It does not persist that plaintext as part of the encrypted output and never holds your private key. Decryption happens entirely on the client.

This is not end-to-end confidentiality against the gateway operator: the gateway must be trusted while it processes plaintext. Neither client-held keys nor Wasm isolation proves that an operator-controlled runtime did not copy that plaintext or that it ran the selected transform. Maskura does not currently provide execution attestation or computation proofs. See the operator trust boundary.

The primitives

RolePrimitiveWhy
Data cipherAES-256-GCMAuthenticated encryption; 256-bit keys resist Grover (~2^128)
Classical KEMX25519 (ECDH)Protects against an undiscovered weakness in a young PQ scheme
Post-quantum KEMML-KEM-768 (FIPS 203)Protects against a cryptographically relevant quantum computer
CombinerHKDF-SHA256Folds the two shared secrets into one DEK

The construction is a hybrid KEM: the DEK is secure unless both X25519 and ML-KEM-768 are broken. This is the consensus position of the IETF hybrid key-exchange draft, NSA CNSA 2.0, and BSI.

The data path contains no signatures, so ML-DSA and SLH-DSA are out of scope — KEM-only.

Encapsulation (encrypt)

For each detected field the gateway:

m            = 32 random bytes from a CSPRNG
(mlkem_ss, mlkem_ct) = ML-KEM-768.Encaps(ek, m)        # ek = your ML-KEM public key
x25519_sk    = 32 random bytes
x25519_epk   = X25519(x25519_sk, basepoint)
x25519_ss    = X25519(x25519_sk, your_x25519_pk)
dek          = HKDF-SHA256(ikm = x25519_ss ‖ mlkem_ss,
                           salt = "",
                           info = "maskura/hybrid/envelope-dek/v1",
                           L = 32)
enc_dek      = x25519_epk ‖ mlkem_ct                  # 1120 bytes
field        = AES-256-GCM(dek, iv, plaintext)

The wrapped key material (enc_dek) carries everything you need to decrypt: your X25519 ephemeral public key and the ML-KEM-768 ciphertext. The DEK itself is never stored — it is derived from the two shared secrets on both sides.

Decapsulation (decrypt, client-side)

(x25519_epk, mlkem_ct) = split(enc_dek)
x25519_ss = X25519(your_x25519_sk, x25519_epk)
mlkem_ss  = ML-KEM-768.Decaps(your_mlkem_dk, mlkem_ct)
dek       = HKDF-SHA256(same construction as above)
plaintext = AES-256-GCM.Decrypt(dek, iv, ct ‖ tag)

The envelope

Each encrypted field becomes a JSON object with five fields:

{"alg":"X25519+ML-KEM-768/AES-256-GCM","iv":"<b64>","enc_dek":"<b64>","ct":"<b64>","tag":"<b64>"}
  • alg identifies the construction. Existing RSA-OAEP/AES-256-GCM envelopes remain decryptable (dual-alg read); new writes are hybrid-only.
  • iv is the 12-byte AES-GCM nonce.
  • enc_dek is the 1120-byte hybrid encapsulation (base64).
  • ct / tag are the AES-256-GCM ciphertext and authentication tag.

Key format

Keys are a single base64 blob of fixed-length concatenation, wrapped in PEM:

-----BEGIN MASKURA HYBRID PUBLIC KEY-----
base64( x25519_pk ‖ mlkem_ek )        # 32 + 1184 = 1216 bytes
-----END MASKURA HYBRID PUBLIC KEY-----

-----BEGIN MASKURA HYBRID PRIVATE KEY-----
base64( x25519_sk ‖ mlkem_seed )      # 32 + 64 = 96 bytes
-----END MASKURA HYBRID PRIVATE KEY-----
ComponentSize
X25519 public key / shared secret32 B
ML-KEM-768 encapsulation key (public)1184 B
ML-KEM-768 ciphertext1088 B
ML-KEM-768 shared secret32 B
ML-KEM-768 seed (private key serialization)64 B
Hybrid public key1216 B
Hybrid private key96 B
enc_dek1120 B

The gateway’s public-key-pem config field is unchanged: it is still a string. The private key never touches the gateway.

Client tooling status

Current gateway releases accept only MASKURA HYBRID PUBLIC KEY PEM blocks for new encrypted writes. MaskuraClient.generate_keypair() in Python and MaskuraClient.generateKeypair() in TypeScript now create that exact hybrid format. Their decrypt helpers decapsulate current hybrid envelopes locally and also retain dual-algorithm read compatibility for old RSA envelopes. Explicit generate_legacy_rsa_keypair() and generateLegacyRsaKeypair() helpers exist only for pre-hybrid gateways and compatibility fixtures.

The CLI provides the same safe path: maskura key create --generate-encryption-key --private-key-out <path> attaches the public key to the newly created API key and writes the private key locally with create-new semantics (mode 0600 on Unix). maskura get ... --decrypt <path> decrypts hybrid envelopes before writing the object to stdout. The private key is never sent to Maskura.

Python hybrid support requires cryptography >= 47. TypeScript uses the Noble X25519, HKDF-SHA256, and ML-KEM implementations while retaining CommonJS output. SDK and CLI tests exercise the gateway wire sizes and round-trip behavior. The Python and TypeScript implementations have also been verified against the deterministic envelope produced by the Rust gateway reference implementation.

Security properties

  • IND-CCA2. Both KEMs are CCA-secure; the HKDF combiner produces an indistinguishable DEK unless both are broken.
  • Per-field keys. A fresh DEK per field means a compromise of one DEK does not expose any other field.
  • Authenticated encryption. AES-GCM binds the ciphertext to its tag; any tampering fails decryption.
  • Fail closed. If key encapsulation or encryption fails, the field is redacted rather than emitted as plaintext.
  • Single-alg write. New writes are hybrid-only; legacy RSA keys are rejected for new writes and remain only for decrypting previously written objects.

Cost

The hybrid wrap trades a slightly larger key encapsulation for materially cheaper CPU than the RSA-OAEP wrap it replaces:

  • ~34M Wasm fuel per encrypted field (vs ~52M for RSA-2048 OAEP).
  • enc_dek grows from 256 B (RSA-2048) to 1120 B per field.

Measured numbers and methodology are in Benchmarks.

Post-quantum encryption

Status: shipped. Current builds wrap the data key with a hybrid X25519 + ML-KEM-768 key encapsulation. See Encryption for the details and ADR 0011 for the decision.

What it is

Maskura’s encryption filters protect fields with envelope encryption: a fresh AES-256-GCM key per field, wrapped with a public key so only the key holder can decrypt. Before this change that wrap was RSA-OAEP, which a quantum computer can break.

The post-quantum feature replaces the RSA-OAEP wrap with a hybrid X25519 + ML-KEM-768 key encapsulation:

  • ML-KEM-768 (NIST FIPS 203) protects against a cryptographically relevant quantum computer (harvest-now-decrypt-later).
  • X25519 protects against the risk of an undiscovered weakness in a young post-quantum scheme.

The data cipher stays AES-256-GCM, which is already post-quantum-safe.

What changes

  • New envelope alg: X25519+ML-KEM-768/AES-256-GCM.
  • New client key format (hybrid public/private keys).
  • Existing RSA-OAEP envelopes remain decryptable (dual-alg read).

What does not change

  • The plugin model, the transform/finish interface, and the sandbox are untouched — this is a new filter implementation, not a new runtime.
  • API-key secrets (KeyWrapping) and KMS/Vault wrapping are symmetric and already safe.

See also

Gateway Filter Pipeline Benchmarks

Measured per-object cost of routing writes through Maskura’s Wasm plugin pipeline. Each number below is the median over a self-tuned run on the Wasm runtime micro-benchmark; fuel is Wasmtime’s deterministic instruction counter (the reproducible cross-machine metric), and wall-clock time is indicative for one Apple Silicon machine.

Run it

just bench-plugins

This builds the filter components and prints the full sweep. It writes machine readable results to target/benchmarks.csv and run provenance to target/benchmarks.metadata (UTC timestamp, source revision/change, dirty state, OS, architecture, processor, Rust, and Cargo versions). It drives FilterEngine directly—no network or storage—so it isolates plugin cost. The sweep is 7 components x 3 record sizes (1 KB / 64 KB / 1 MB) x 4 requested PII counts (0 / 1 / 10 / 100).

The reference tables below were recorded on 2026-09-10 using an Apple M4 Pro (arm64), macOS 26.6.2, Rust/Cargo 1.98.1, and the production release profile. Wall-clock results will move with hardware and system load; fuel should be the comparison metric across machines.

What each metric means

  • fuel — Wasmtime fuel consumed per object (Wasm instruction count proxy). Deterministic across machines for the same component and payload.
  • fuel/byte — marginal scan cost; the fixed per-object overhead is small relative to 1 MB records, so large records expose the true per-byte cost.
  • ms/object — median wall-clock per object (machine-dependent).
  • MiB/s — effective single-object throughput.
  • expansion — output bytes / input bytes (crypto envelopes inflate output).

Results

1. Fixed per-object overhead (noop)

A noop filter still pays one fresh Wasm store, begin, one transform, and finish per object. Fuel is flat regardless of size:

sizefuelms/objectMiB/s
1 KB18,3960.05218
64 KB18,5920.084730
1 MB18,5920.5671683

2. PII scan cost (redaction filters, 1 MB, 0 PII)

Detection/redaction scans every byte; cost is linear in size:

pluginfuel/bytems/objectMiB/s
pii-default (email+card+ssn)49.52.48385
email-detect41.52.31413
ssn-detect45.52.17440
card-detect45.52.10454

3. Envelope encryption (hybrid X25519 + ML-KEM-768 + AES-256-GCM)

Cost is dominated by one hybrid key encapsulation per detected field, ~35M fuel per field (down from ~52M for the RSA-2048 OAEP wrap it replaced). The encapsulation is larger — 1120 B per field vs 256 B — so expansion grows for dense records:

recordfieldsfuelms/objectMiB/sexpansion
1 KB135.1M0.921.05.86x
1 MB1003.42G86.711.01.49x

4. Stable (deterministic) encryption (AES-SIV)

This filter parses and re-serializes the whole JSON record (serde), so cost scales with record size, plus ~170K fuel per encrypted field:

recordfieldsfuelms/objectMiB/s
1 MB01.02M0.591609
1 MB129.6M1.50637
1 MB10046.8M1.97483

Cost summary (multiplier vs. noop, 1 MB)

pluginfuel/bytevs noop (fuel)MiB/s
noop~0.021x1683
stable-encrypt (0 fields)~1.0~55x1609
envelope-encrypt (0 fields)28.6~1540x563
email-detect41.5~2230x413
ssn-detect45.5~2450x440
card-detect45.5~2450x454
pii-default49.5~2660x385

The vs noop column is absolute fuel; for a fixed per-object budget the marginal per-byte figure (fuel/byte) is the number to plan capacity against.

Caveats

  • Built with the production release profile (opt-level = "s", lto, 1 CU). A speed-optimized profile would lower wall-clock times; fuel is unaffected.
  • Fuel budget for this sweep was raised to 1e10 to measure crypto work rather than the production 1e9 ceiling. At the default budget, envelope-encrypt exhausts fuel at roughly 27 encrypted fields per object.
  • A text record is padded/truncated to the target size; a small record with a high requested PII count therefore carries fewer actual tokens (see the CSV for exact input bytes).
  • envelope-encrypt numbers assume a valid hybrid X25519 + ML-KEM-768 public key in the session context and host-generated entropy; stable-encrypt uses a fixed 64-byte key and tagged fields.

Avro OCF support

Avro Object Container File processing is a typed binary format, intentionally separate from JSON/CSV text processing: an Avro writer must know the complete output schema before it emits the first object.

Enablement

Avro processing is off by default. Operators enable it with MASKURA_ENABLE_AVRO=true. Disabled requests are rejected before the request body is polled. Raw GET, HEAD, and Range passthrough for stored Avro objects do not require the gate; only processing (PUT, processed GET, staged multipart completion) does.

Supported codec subset

The codec accepts:

  • OCF records with null, boolean, int, long, float, double, bytes, and string values.
  • Records, arrays, maps with string keys, and exactly nullable unions of the form ["null", T].
  • Logical date, time-millis, time-micros, timestamp-millis, timestamp-micros, timestamp-nanos, uuid, and decimal values.
  • Nested combinations of those types, subject to Maskura schema/value IR limits.

It rejects recursive/named references, arbitrary unions, enums, and fixed values. Input OCF blocks may use null, deflate, snappy, or zstandard; generated output uses Zstandard with normalized metadata. Source input is capped before the Avro library reads it, and every emitted value is validated against the bounded Maskura IR schema.

Processing model

Each processed object follows this order:

Avro OCF -> Schema/Value IR -> BinaryReductor -> BinaryTransform
         -> BinaryReductor restore -> validated Schema/Value IR -> Avro OCF

Text maskura:filter plugins are not inserted in this flow. They receive opaque bytes and cannot declare an output schema. Consequently, signed TOML WASM write/read assignments do not apply to Avro processing; Avro policy is the schema-aware binary pipeline described here.

Envelope encryption

x-maskura-encrypt-fields selects comma-separated string schema paths, for example email or contacts[*].email. With an authenticated public key, selected fields become X25519+ML-KEM-768/AES-256-GCM envelope records and the Avro schema is evolved before encoding. Without a public key, selected fields are redacted to [REDACTED] while the string schema is preserved. Overlapping or invalid paths are rejected. Multipart completion uses identity processing; field selection applies to single PUT and processed GET.

Multipart and processed reads

Staged multipart completion concatenates the encrypted parts into one OCF source and runs it through the same processor rather than treating parts as independent streams. A processed read (x-maskura-process: read) runs the source through the typed pump and stages the complete output in the existing encrypted read spool before disclosure. Processed read failures never disclose raw source bytes.

Verify codec work

cargo test -p maskura-gateway avro::tests
cargo test -p maskura-gateway binary_pump::tests

The suite covers OCF schema/value round trips, nullable/container records, logical values, decimal precision/scale, source-size boundaries, transport chunk invariance, unsupported schemas, and processing through a typed transform.

Hive-compatible layouts

Maskura treats a Hive partition path as an ordinary object key. For example:

warehouse/customers/day=2026-08-30/part-000.avro

The Avro schema remains in the OCF header. Maskura does not provide a Hive Metastore, table DDL, SQL query engine, or ORC support.

Binary adapters

Typed binary formats such as Avro and Parquet do not pass through the byte-oriented maskura:plugin/transformer pipeline. A binary encoder needs its complete output schema before it writes the first record. Use a binary reductor when a format-specific logical type must be converted to a Maskura-supported type before a typed transform, then reconstructed for output.

The binary-reductor world is part of maskura:plugin@0.1.0 in crates/plugin-sdk/wit/world.wit.

Lifecycle

For one object, the host calls the component in this order:

  1. plan(source-schema-ir) returns a reduced schema, owned claims, and an opaque reduction plan.
  2. reduce(plan, source-value-ir) runs once per source value.
  3. The host applies schema-aware binary transforms to reduced values.
  4. plan-restore(source-schema-ir, transformed-reduced-schema-ir, plan) returns the final output schema and an opaque restoration plan.
  5. restore(restore-plan, transformed-value-ir) runs once per retained value.

The component only owns paths it claims. The gateway verifies that claims point to a custom logical value or declared record, and rejects a schema mutation outside a claim. Plans are bound to the SHA-256 digest of the exact component; do not reuse plans across component versions.

Canonical IR

Schema and value inputs are canonical JSON representations of the bounded Maskura IR. The definitive Rust types and validators are in crates/gateway/src/binary_ir.rs.

  • A nullable Avro-like field is represented by "nullable": true, not an arbitrary union.
  • Custom logical values use {"type":"custom","type_id":"...","value":...}.
  • Map keys are UTF-8 strings and map entries are canonicalized by key.
  • The gateway validates every returned schema and value before it reaches an encoder. Invalid or unsupported data must return reductor-error, never a best-effort result.

The test fixture in tests/plugins/binary-reductor/src/lib.rs is the smallest complete example. It reduces vendor.money from a custom value to a string and restores it after the typed transform.

Write an adapter

Create a cdylib crate that uses the workspace-compatible wit-bindgen release:

[lib]
crate-type = ["cdylib"]

[dependencies]
wit-bindgen = "0.60"

Generate bindings and implement the exported Guest trait:

#![allow(unused)]
fn main() {
wit_bindgen::generate!({
    world: "binary-reductor",
    path: "path/to/maskura/crates/plugin-sdk/wit",
});

struct MyReductor;

impl Guest for MyReductor {
    // Implement plan, reduce, plan_restore, and restore.
}

export!(MyReductor);
}

Build a bare component. Binary reductors receive no WASI and no other host imports, so do not use the WASI adapter used by text plugins:

cargo build --release --target wasm32-unknown-unknown
wasm-tools component new target/wasm32-unknown-unknown/release/my_reductor.wasm \
  -o my-reductor.component.wasm

Required behavior

  • Keep every returned IR, plan, claim, identifier, and diagnostic within the host limits. The host rejects oversized output.
  • Claim each custom logical subtree that the component changes. Claims may not overlap or prefix one another.
  • Treat plan and plan-restore output as immutable. Preserve all state needed later in opaque plan bytes.
  • Return stable error codes and bounded diagnostics. Never include plaintext, keys, or full source records in diagnostics.
  • Do not depend on filesystem, network, clocks, environment variables, or host imports. The binary-reductor sandbox intentionally provides none.

Test locally

From a Maskura checkout:

bash scripts/build-plugins.sh
cargo test -p maskura-wasm-runtime binary_reductor
cargo test -p maskura-gateway binary_reductor::tests

Add conformance vectors beside the fixture for each new logical type. Cover round trips, invalid claims, invalid plan bytes, malformed IR, fuel exhaustion, deadlines, cancellation, and component-digest changes before connecting an adapter to a codec.

Current integration boundary

The Wasm runtime and gateway adapter are available to codec code. Runtime component selection for binary formats is intentionally separate from dashboard text-plugin upload: a byte filter cannot safely become a binary adapter merely by changing its file extension. A codec integration must explicitly select and pin its binary-reductor component.

MCP server

maskura-mcp is a local stdio Model Context Protocol server. It exposes four text object tools to Claude, Codex, Cursor, and other MCP clients:

  • maskura_put_object
  • maskura_get_object
  • maskura_list_objects
  • maskura_delete_object

The stdio server does not implement a second storage or processing path. Every tool calls the Maskura Gateway’s S3-compatible HTTP surface, so gateway authentication, the configured plugin pipeline, backend selection, limits, and metering still apply. Credential-bearing connections require HTTPS; cleartext HTTP is accepted only for literal loopback hosts (localhost, 127.0.0.0/8, or ::1).

Install

Build and install from the public source:

cargo install --git https://github.com/231self/maskura --bin maskura-mcp maskura-mcp

Linux x86_64 and arm64, plus native Apple Silicon, binaries are attached to each Maskura GitHub release as maskura-mcp-linux-amd64, maskura-mcp-linux-arm64, and maskura-mcp-macos-arm64. The maskura-mcp binary and maskura_* tools remain permanent compatibility aliases.

There is currently no npm package. The operated Maskura service provides a hosted Streamable HTTP endpoint, while the public gateway provides its shared typed contracts in maskura-mcp-protocol (re-exported as maskura_gateway::mcp) and trusted in-process execution through maskura_gateway::server::invoke_mcp.

Run locally

Start the published Maskura gateway image through the CLI and copy the loopback URL it prints. The CLI calls the local Docker API directly; this starts one Maskura container with its own durable FileStore and S3-compatible API, not a MinIO sidecar:

maskura local init
# Gateway: http://127.0.0.1:8080 (the selected port may differ)

The local gateway runs with AUTH_DISABLED=true. maskura-mcp still requires an explicit credential shape so a configuration cannot accidentally become credential-free when pointed at production. Use local-only placeholder values:

{
  "mcpServers": {
    "maskura-local": {
      "command": "maskura-mcp",
      "env": {
        "MASKURA_GATEWAY_URL": "http://127.0.0.1:8080",
        "MASKURA_ACCESS_KEY": "local",
        "MASKURA_SECRET_KEY": "local"
      }
    }
  }
}

Use the exact port printed by maskura local init. These placeholder credentials are accepted only because that loopback gateway explicitly disables authentication; never use AUTH_DISABLED on a network-accessible deployment.

For Kilo, the equivalent local entry in kilo.json is:

{
  "mcp": {
    "maskura-local": {
      "type": "local",
      "command": ["maskura-mcp"],
      "environment": {
        "MASKURA_GATEWAY_URL": "http://127.0.0.1:8080",
        "MASKURA_ACCESS_KEY": "local",
        "MASKURA_SECRET_KEY": "local"
      },
      "enabled": true
    }
  }
}

Connect directly over Streamable HTTP

Create an MCP token in the Maskura dashboard. A remote MCP client that supports Streamable HTTP needs these connection values:

URL: https://api.maskura.dev/mcp/workspaces/<workspace-id>
Authorization: Bearer maskura_mcp_<token>

Use the immutable workspace ID associated with the token. The workspace in the URL must match exactly; Maskura never resolves a current or default workspace at request time. Native clients should omit Origin. Browser clients are accepted only from the operated dashboard’s exact configured origin.

The endpoint is stateless and accepts JSON-RPC over POST. It enforces bounded request and response envelopes, canonical Host, workspace-bound credentials, durable audit preflight, usage authorization, and per-user concurrency and rate limits. Supabase JWTs, S3 API keys, forwarded identity headers, and caller-chosen operation or policy identities are not accepted by this route.

Client configuration shapes differ. Set the URL as the client’s remote or Streamable HTTP MCP URL and send the token only in the Authorization header; keep it in the client’s secret storage rather than source control.

Connect the local server to a hosted gateway

Create an MCP token in the Maskura dashboard, or through the dashboard API with a signed-in session JWT:

curl --fail-with-body \
  --request POST "$MASKURA_GATEWAY_URL/dashboard/api/mcp-tokens" \
  --header "Authorization: Bearer $MASKURA_SESSION_JWT" \
  --header "Content-Type: application/json" \
  --data '{"label":"desktop-agent","expires_in":2592000}'

The response reveals the maskura_mcp_... token once. Store it in a secret manager, not in source control. The token remains bound to the workspace selected when it was created.

Claude Desktop and Cursor use the standard mcpServers shape:

{
  "mcpServers": {
    "maskura": {
      "command": "maskura-mcp",
      "env": {
        "MASKURA_GATEWAY_URL": "https://api.maskura.dev",
        "MASKURA_MCP_TOKEN": "maskura_mcp_your_token"
      }
    }
  }
}

For Claude Desktop on macOS, place this under the mcpServers key in ~/Library/Application Support/Claude/claude_desktop_config.json. Cursor uses .cursor/mcp.json in a project or its equivalent global MCP settings.

Kilo uses its local-process MCP configuration shape in kilo.json:

{
  "mcp": {
    "maskura": {
      "type": "local",
      "command": ["maskura-mcp"],
      "environment": {
        "MASKURA_GATEWAY_URL": "https://api.maskura.dev",
        "MASKURA_MCP_TOKEN": "maskura_mcp_your_token"
      },
      "enabled": true
    }
  }
}

Restart the client after changing its MCP configuration. Desktop applications often do not inherit shell environment variables, so use the client’s secret storage or a restricted configuration file when literal values are required.

A Maskura API key pair can be used instead:

{
  "MASKURA_GATEWAY_URL": "https://api.maskura.dev",
  "MASKURA_ACCESS_KEY": "maskura_your_access_key",
  "MASKURA_SECRET_KEY": "maskura_secret_your_secret_key"
}

MASKURA_MCP_TOKEN takes precedence when both credential forms are present. Secret values are validated at startup and are omitted from debug output. Legacy MASKURA_* names remain accepted. If both forms are set, their values must match exactly, including empty values, or startup fails closed.

Try it locally

The stdlib-only example connects over MCP stdio and lists the available tools:

export MASKURA_GATEWAY_URL="http://127.0.0.1:8080" # use the printed port
export MASKURA_ACCESS_KEY="local"
export MASKURA_SECRET_KEY="local"
python3 examples/mcp-client.py

Run a complete put, filtered get, paged list, and delete lifecycle:

MCP_EXAMPLE_RUN_MUTATIONS=1 \
MCP_EXAMPLE_BUCKET=agent-data \
python3 examples/mcp-client.py

The upload contains alice@example.com; the read result should contain [REDACTED_EMAIL], proving the MCP call used the normal gateway filter path.

Equivalent requests from an MCP-enabled agent are:

Store "Contact alice@example.com" at agent-data/examples/mcp.txt with Maskura.
Read agent-data/examples/mcp.txt and show me the stored value.
List up to 10 keys under examples/ in agent-data.
Delete agent-data/examples/mcp.txt.

Tool behavior

maskura_put_object accepts a UTF-8 body and a content_type (default text/plain; charset=utf-8). Maskura uses that Content-Type to select the processing format before writing to the configured backend.

maskura_get_object returns the stored representation by default. Set process to true to send x-maskura-process: read and run the configured read pipeline before the MCP client receives the object.

maskura_list_objects performs S3 ListObjectsV2 with an optional prefix and returns decoded object keys. maskura_delete_object deletes one bucket/key pair.

MCP text responses are limited to 8 MiB. Binary request/response bodies, presigning, and agent payment protocols are not part of the stdio or hosted transport.

Hosted adapter boundary

A hosted adapter authenticates its transport session outside the engine, then calls invoke_mcp with:

  • an atomically resolved AuthenticatedMcpPrincipal containing the credential UUID, derived policy identity, user, and immutable workspace
  • a server operation UUID
  • a typed ToolRequest
  • request/response byte limits, timeout, and cancellation token

The invocation enters the same gateway handlers used by S3, including control plane authorization, plugin resolution and filtering, workspace storage, transactions, and usage recording. It does not use loopback HTTP and does not accept credential, metering, backend-selection, or presigned URL headers. Cancellation interrupts active Wasm work and waits for route settlement before returning. Provider SDK calls do not expose a cooperative cancellation guarantee, so an in-flight backend call may finish first; if it commits, Maskura returns the settled committed outcome instead of reporting or releasing it as cancelled.

API keys and MCP tokens are bound to the workspace selected when they are created. User identity is retained separately so dashboard owners can list and revoke credentials. Credentials persisted before workspace binding was added remain visible for rotation but fail authentication; Maskura never infers a default workspace for them.

Local end-to-end suite

The gateway ships a local, no-secrets end-to-end suite that boots a real MinIO S3 backend plus the gateway binary and exercises public HTTP features against it. It runs in CI (ci.yml, weekly.yml, nightly.yml) and locally, and needs no Supabase, Postgres, cloud credentials, or signed-in users.

just e2e                  # run the full suite
bash scripts/e2e-local.sh # same, without `just`

This suite intentionally validates the gateway’s external S3 backend path using MinIO. It is separate from the standalone Docker deployment used by customers. For the standalone path, run the Maskura-managed container instead:

maskura local init
# Maskura itself is the S3 endpoint; no MinIO container is started.

That command uses the Docker API to run one published gateway container with the maskura-local-keys volume mounted at /data, MASKURA_STORAGE_MODE=local, and durable staged multipart enabled. Use the exact loopback URL and credentials it prints with aws s3, boto3, or another S3 client.

How it is structured

  • scripts/e2e-local.sh — the orchestrator. Boots the shared environment once, runs every feature script in scripts/e2e/features/, aggregates PASS/FAIL, and exits 0 only when all features pass.
  • scripts/e2e/lib.sh — the shared harness contract and helpers (pass/fail, HTTP-status and content assertions, JSON field extraction).
  • scripts/e2e/features/NN-*.sh — one discrete, independently-runnable feature script per concern. Feature order is lexicographic; stateful features (plugin management) run last.

A feature may also be run alone against a freshly booted environment:

bash scripts/e2e-local.sh 30-keys-s3-lifecycle
# accept a bare feature number/name or a path:
bash scripts/e2e-local.sh 30-keys-s3-lifecycle.sh

Boot contract

The orchestrator starts (or reuses) one shared environment for every feature:

  • MinIO on :9000 — if a healthy MinIO is already listening there (minioadmin/minioadmin), it is reused instead of failing on the port clash (common when a dev/ad MinIO is running). Otherwise docker compose -f local/docker-compose.yml up -d minio starts one, torn down on exit.
  • Bucket maskura-local is created if missing.
  • Gateway (AUTH_DISABLED) on $MASKURA_E2E_GW_PORT (default 9010), single-tenant streaming against that MinIO, an isolated keys.json, the built pii-default component, and MASKURA_STREAMING_READ_MODE=passthrough.
  • Gateway, MinIO, and the filter/binaries are built from the working tree each run, so the suite validates the code you have checked out.

Requirements: bash, Docker + docker compose, curl, python3, and a Rust toolchain with the WASM/WASI targets used by scripts/build-plugins.sh. No environment variables, secrets, or network access are required.

Note: with AUTH_DISABLED=true, unauthenticated and unresolvable-credential requests are admitted as the demo user. Key expiry/revocation rejection therefore cannot be asserted against the shared gateway; the strict-auth feature (below) covers denial semantics on a second gateway instead.

Features

ScriptConcernWhat it proves
10-http-surface.shHTTP surface/health; / serves the dashboard HTML and is not an S3 ListBuckets XML response; /openapi.json is OpenAPI 3.1 and documents /dashboard/api/keys and /dashboard/api/backend; /docs serves the self-contained OpenAPI reference; retired /dashboard/api/demo/store returns 410 for every method
15-avro-gate.shAvro gateA PUT with Content-Type: application/avro is rejected (501) while MASKURA_ENABLE_AVRO is unset, and nothing is stored
20-redaction-roundtrip.shCore redactionmaskura test upload stores a PII fixture through the pipeline; the object read directly back from MinIO contains [REDACTED_EMAIL]/[REDACTED_SSN]/[REDACTED_CARD] and no plaintext
25-strict-auth-denial.shAuth enforcementBoots a second, isolated gateway on $MASKURA_STRICT_GW_PORT (default 9011) without AUTH_DISABLED and an empty keystore: unauthenticated S3 PUT/GET/List are denied 403 and the dashboard key API is denied 401 — no demo fallback
30-keys-s3-lifecycle.shKeys + S3 data planeDashboard key create / list / revoke happy paths (maskura_/maskura_secret_ formats, revoke returns 204 and removes the key); header-authenticated S3 PUT → HEAD → GET byte-identical read-back → ListObjects v1 and v2 via the real MinIO backend → DELETE → 404
40-plugin-admin-http.shPlugin managementImport a real .wasm component (201), catalog list, enable (200 + enabled: true), reorder, and remove (204) over the HTTP admin routes mounted in AUTH_DISABLED mode. Runs last because enabling an imported component can change the write pipeline

Each feature prints PASS:/FAIL: lines and exits non-zero on failure, so a feature can also be executed directly against an already-booted environment.

What the suite covers beyond the old e2e

The original e2e only exercised GET /health and one demo-mode test upload. The suite now also asserts previously untested public paths:

  • S3-backend ListObjects v1 + v2 and byte-faithful authenticated PUT/HEAD/GET/DELETE against a real S3 backend (MinIO).
  • GET/DELETE /dashboard/api/keys happy paths and the dashboard key format.
  • Dashboard HTML, /openapi.json + /docs serving, and legacy tombstone 410s.
  • Plugin import / list / enable / reorder / remove across the real HTTP router.
  • Data-plane + dashboard denial on a non-AUTH_DISABLED boot (no demo fallback).
  • The Avro enablement gate (negative path).

Deferred scenarios (and why they are separate harnesses)

The following need a different gateway boot contract or extra tooling, so they are deliberately not part of this suite yet. Each is a natural future NN-*.sh with its own boot:

  • Positive Avro round trip and envelope/stable field encryption — need MASKURA_ENABLE_AVRO=true, an authenticated public key, OCF fixtures, and an Avro codec (e.g. fastavro) on the runner to assert typed output. See Avro OCF support.
  • Managed service storage — needs a multi-backend boot with MASKURA_SERVICE_BUCKETS and no S3_ENDPOINT (the two are mutually exclusive at startup).
  • Staged multipart — local mode is covered without Postgres or MinIO by the standalone filesystem multipart tests and AWS SDK conformance suite. Hosted staged multipart still needs Postgres, a durable KEK, and the configured staging backend (MULTIPART_MODE=staged + DATABASE_URL); it remains covered by the Postgres-gated db_keys_test CI job.
  • Key expiry/revocation rejection on the data plane — needs an auth-enabled boot that can create keys (with AUTH_DISABLED unset, the dashboard key API requires a real user session).
  • Presigned URL proxy — needs a container-network harness: the host-run gateway cannot deterministically reach the local MinIO loopback over IPv4 on all Docker setups, and the SSRF allowlist rejects IP-literal hosts.
  • SDK / MCP live round trips — need the Python/TypeScript SDK or MCP runtime dependencies on the runner.

Adding a feature

  1. Create scripts/e2e/features/NN-short-name.sh that sources ../lib.sh, calls begin_feature, asserts with expect_status / assert_contains / assert_absent, and ends with end_feature "<name>".
  2. Use unique object keys and, when mutating the gateway state, place the feature last (as 40-plugin-admin-http.sh does).
  3. If the feature needs its own gateway, boot it on its own port and keys file and stop it on EXIT (see 25-strict-auth-denial.sh).
  4. Keep it deterministic in CI: only bash, curl, python3, Docker, and the artifacts the orchestrator already builds. Remember curl --data-binary implies POST unless -X PUT is given.

Troubleshooting

  • If MinIO is already running on :9000 the orchestrator reuses it; give it a moment or confirm curl http://127.0.0.1:9000/minio/health/live succeeds.
  • The gateway log is written to the isolated run directory ($E2E_KEYS_DIR/gateway.log) and removed on exit; run a single feature and check the FAIL: lines for the exact assertion that broke.

Configuration reference

Maskura accepts non-secret gateway settings in TOML. The schema is strict: unknown tables or keys, malformed values, and invalid combinations stop startup. Credentials, tokens, and the service-storage backend list are environment-only and are rejected if placed in TOML.

Loading and validation

The gateway discovers one configuration file in this order:

  1. maskura-gateway --config <path>
  2. MASKURA_CONFIG=<path>
  3. ./maskura.toml, if it exists
  4. No file

If a file selected with --config or MASKURA_CONFIG does not exist, Maskura reports an error. Without a file, Maskura uses its built-in defaults and any environment variables. Settings are applied in this order:

compiled defaults < TOML file < environment overrides

Validate the same configuration without starting the gateway:

maskura config --check
maskura config --check --config /etc/maskura/maskura.toml

The command finds and validates the file in the same way as the gateway. It also applies non-secret environment overrides and checks settings that depend on one another. It exits with a non-zero status if the selected file is missing or the configuration is invalid. Credentials and other environment-only values, such as MASKURA_SERVICE_BUCKETS, are checked when the gateway starts. Run the check with the same environment you plan to use for the gateway.

TOML booleans are true or false. Boolean environment overrides accept true/false or 1/0. Byte values are unsigned integers. List overrides are comma-separated; surrounding whitespace and empty entries are removed.

TOML schema

Every table and field is optional. “Unset” below means that no value is supplied by the configuration file or environment; the gateway may still apply the default described in the table.

Server and authentication

TOML keyEnvironment overrideDefault and behavior
server.listen_addrLISTEN_ADDRUnset. Listens on 0.0.0.0:9000 in automatic local-appliance mode and 0.0.0.0:8080 otherwise. Must be a socket address.
auth.disabledAUTH_DISABLEDfalse. Bypasses authentication and implies explicit single-tenant mode; use only for local development.

Storage and Supabase

TOML keyEnvironment overrideDefault and behavior
storage.modeMASKURA_STORAGE_MODEUnset; the only accepted configured value is local. Mutually exclusive with storage.s3_endpoint.
storage.s3_endpointS3_ENDPOINTUnset. HTTP(S) URL for process-global S3-compatible storage; allowed only in explicit single-tenant mode. Credentials remain environment-only.
storage.s3_regionS3_REGION, then AWS_REGION, then AWS_DEFAULT_REGIONUnset; effective S3 client region is us-east-1. The first non-empty override wins.
storage.single_tenantMASKURA_SINGLE_TENANTfalse. Enables global or local storage for a single-tenant deployment.
storage.local_dirMASKURA_LOCAL_STORAGE_DIRUnset. Selects local storage at this path and requires single-tenant mode. storage.mode = "local" without a path uses ./data; automatic local-appliance mode uses /data. Local storage is mutually exclusive with global S3 and service storage.
supabase.urlSUPABASE_URLUnset; effective local default is http://127.0.0.1:54321.

If you do not configure service buckets, a database, a global S3 endpoint, or local storage, Maskura starts as a local appliance. It stores data in /data, enables staged multipart uploads, saves local credentials, listens on port 9000, and leaves plugins disabled. Settings for multipart uploads, streaming reads, and filter components replace these defaults when provided.

Features

TOML keyEnvironment overrideDefault and behavior
features.multipart_modeMASKURA_MULTIPART_MODEreject; accepted values are reject and staged. Local staged mode uses the local root. Hosted staged mode requires the complete staging configuration and env-only dependencies.
features.streaming_read_modeMASKURA_STREAMING_READ_MODEoff; accepted values are off, passthrough, and transformed. transformed also requires transformed_read_spool = true.
features.transformed_read_spoolMASKURA_TRANSFORMED_READ_SPOOLfalse. The environment value enables it only when equal to encrypted (case-insensitive). Required for transformed reads with any component not declared prefix-safe.
features.enable_avroMASKURA_ENABLE_AVROfalse. Enables Avro processing.
features.streaming_s3_providerMASKURA_STREAMING_S3_PROVIDERUnset; accepted values are aws, minio, r2, and b2. Required so Maskura can verify support for direct streaming to global S3.
features.dev_memory_streamingMASKURA_DEV_MEMORY_STREAMINGfalse. Enables the development in-memory streaming path outside explicit single-tenant mode.
features.managed_streaming_modeMASKURA_MANAGED_STREAMING_MODEoff; accepted values are off, observe, and enforce. observe/enforce require transactional mode and durable hosted dependencies.
features.managed_streaming_transactionalMASKURA_MANAGED_STREAMING_TRANSACTIONALfalse. Declares that the managed backend supports the required transactions.

Wasm pipeline

TOML keyEnvironment overrideDefault and behavior
wasm.filter_componentMASKURA_DEFAULT_PLUGINUnset; uses the bundled/default pii-default component path. Automatic local-appliance mode starts with all plugins disabled unless this field is explicitly configured.
wasm.plugins_dirMASKURA_PLUGINS_DIRUnset. Existing components in this directory are loaded at startup.
wasm.fuelMASKURA_WASM_FUEL1,000,000,000 instructions per pipeline session; must be greater than zero.
wasm.prefix_safe_component_hashesMASKURA_PREFIX_SAFE_COMPONENT_HASHESEmpty list. Each entry must be a 64-character hexadecimal SHA-256 digest. Maskura trusts the declaration only for that exact component and does not allow it to change while running.
wasm.pipelines_fileMASKURA_PIPELINES_FILEUnset. Path to a signed TOML pipeline file; when set, replaces catalog-order selection.
wasm.pipeline_trust_rootsMASKURA_PIPELINE_TRUST_ROOTSUnset. Operator-only semicolon- or comma-separated signer_id=hex-public-key entries.
wasm.pipeline_allow_unsignedMASKURA_PIPELINE_ALLOW_UNSIGNEDfalse. Operator-only escape hatch that accepts an unsigned file, labels its revision unsigned-dev, and logs a prominent boot warning. Local development only.

Signed pipeline files

A separate signed TOML file can define processing chains per workspace, bucket, and direction. The gateway verifies its signature and resolves every declared step against the local component catalog before startup succeeds. Qualified file:// sources must match the component’s canonical directory URI exactly; unqualified references search local filesystem components. See ADR 0021 and Plugins.

Author, sign, and inspect a file with maskura pipelines init-key, maskura pipelines sign, maskura pipelines verify, and maskura pipelines check.

Limits and spool

TOML keyEnvironment overrideDefault and behavior
limits.source_max_frame_bytesMASKURA_SOURCE_MAX_FRAME_BYTES8 MiB; must be positive and fit the platform usize.
limits.max_object_bytesMASKURA_MAX_OBJECT_BYTES5 GiB. Positive values may lower but cannot raise the 5 GiB hard limit for source data.
limits.max_pipeline_output_bytesMASKURA_MAX_PIPELINE_OUTPUT_BYTES5 GiB. Positive values may lower but cannot raise the 5 GiB hard limit for pipeline output.
limits.legacy_max_object_bytesMASKURA_LEGACY_MAX_OBJECT_BYTES16 MiB. Positive values may lower but cannot raise the 16 MiB hard limit for the legacy path.
limits.dev_memory_max_object_bytesMASKURA_DEV_MEMORY_MAX_OBJECT_BYTES16 MiB. Positive values may lower it or raise it to at most 64 MiB.
spool.dirMASKURA_SPOOL_DIRThe OS temporary directory plus maskura-spool.
spool.max_object_bytesMASKURA_SPOOL_MAX_OBJECT_BYTESEffective object limit; cannot exceed limits.max_object_bytes.
spool.quota_bytesMASKURA_SPOOL_QUOTA_BYTESTwice the effective spool object limit. Must be positive and at least spool.max_object_bytes.

The pipeline also has fixed limits: at most 16 plugins, 16 MiB intermediate records, 8 MiB of plugin-finish output, 32x expansion plus 1 MiB of slack, and a five-minute processing time.

Keys and managed placement

TOML keyEnvironment overrideDefault and behavior
keys.keys_fileMASKURA_KEYS_FILEUnset. Selects a JSON key store when local storage or Postgres does not take precedence. Local storage defaults to .maskura/keys.json beneath its root.
keys.bootstrap_keyMASKURA_BOOTSTRAP_KEYUnset. Non-secret bootstrap access-key ID; must be paired with env-only MASKURA_BOOTSTRAP_SECRET.
managed.placement_versionMASKURA_MANAGED_PLACEMENT_VERSION1; must be greater than zero. Bump it when changing a durable managed placement policy.

Multipart staging

TOML keyEnvironment overrideDefault and behavior
multipart_staging.endpointMASKURA_MULTIPART_STAGING_ENDPOINTUnset. HTTP(S) URL for the hosted staging object store.
multipart_staging.bucketMASKURA_MULTIPART_STAGING_BUCKETUnset; must not be empty.
multipart_staging.regionMASKURA_MULTIPART_STAGING_REGIONUnset; must not be empty.
multipart_staging.dirMASKURA_MULTIPART_STAGING_DIRUnset; must not be empty. Temporary hosted staging directory.
multipart_staging.tenant_quota_bytesMASKURA_MULTIPART_STAGING_TENANT_QUOTA_BYTESIn local mode, 16 times the effective object limit. Hosted staged mode requires an explicit value. Must be positive and no greater than the global quota.
multipart_staging.global_quota_bytesMASKURA_MULTIPART_STAGING_GLOBAL_QUOTA_BYTESIn local mode, four times the tenant quota. Hosted staged mode requires an explicit value. Must be positive.

Hosted staged mode additionally requires DATABASE_URL, durable key wrapping, and the env-only staging access-key pair. Local staged mode derives all durable state from the local storage root.

SigV4 and outbound allowlists

TOML keyEnvironment overrideDefault and behavior
sigv4.regionMASKURA_SIGV4_REGIONus-east-1; expected SigV4 credential-scope region.
sigv4.trusted_tlsMASKURA_SIGV4_TRUSTED_TLSfalse. Set only when a trusted proxy terminates TLS before the gateway.
allowlists.workspace_endpointMASKURA_WORKSPACE_ENDPOINT_ALLOWLISTEmpty. Hosts or *.suffix entries trusted for multi-tenant persisted workspace endpoints.
allowlists.workspace_endpoint_privateMASKURA_WORKSPACE_ENDPOINT_PRIVATE_ALLOWLISTEmpty. Exact private hosts; valid only in explicit single-tenant mode.
allowlists.presigned_httpMASKURA_PRESIGNED_HTTP_ALLOWLISTEmpty. Hosts or *.suffix entries allowed for presigned HTTP(S) sources.
allowlists.presigned_http_privateMASKURA_PRESIGNED_HTTP_PRIVATE_ALLOWLISTEmpty. Exact hosts allowed to resolve to private addresses.
allowlists.presigned_http_allow_httpMASKURA_PRESIGNED_HTTP_ALLOW_HTTPfalse. Permits HTTP only for presigned source GET; destinations remain HTTPS-only.
allowlists.presigned_http_min_validity_secsMASKURA_PRESIGNED_HTTP_MIN_VALIDITY_SECS30 seconds; must be greater than zero.

Allowlist entries are hostnames, IP literals, or *.suffix patterns as applicable, never URLs. More restrictive runtime DNS and public/private address checks still apply; see Security.

Observability

Telemetry is environment-only and disabled unless an OTLP endpoint is configured. Collector vendor, production endpoint, and credentials are an operations decision; see ADR 0020.

Local logging

Environment variableDefault and behavior
MASKURA_LOG_FORMATtext or json; default text.
MASKURA_LOG_LEVELerror, warn, info, or debug; default info. Applies only to Maskura-owned targets. Dependency targets stay capped at WARN; OpenTelemetry and HTTP-client targets are never written locally.

OTLP export

Environment variableDefault and behavior
OTEL_EXPORTER_OTLP_ENDPOINTBase endpoint; enables traces, logs, and metrics when set. Signal-specific endpoints override it.
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, OTEL_EXPORTER_OTLP_METRICS_ENDPOINTPer-signal endpoints; enable only that signal.
OTEL_TRACES_EXPORTER, OTEL_LOGS_EXPORTER, OTEL_METRICS_EXPORTERotlp or none only; none disables one signal.
OTEL_EXPORTER_OTLP_PROTOCOL (and per-signal variants)http/protobuf only; any other value fails startup.
OTEL_EXPORTER_OTLP_INSECURE (and per-signal variants)false by default. A plaintext http:// endpoint requires the matching true; an https:// endpoint rejects true.
OTEL_EXPORTER_OTLP_HEADERS (and per-signal variants)Comma-separated name=value pairs, at most 32, no CR/LF. Values are never echoed in diagnostics.
OTEL_EXPORTER_OTLP_TIMEOUT (and per-signal variants)Export timeout in milliseconds, 1–300000; default 10000.
OTEL_SERVICE_NAMENon-empty bounded printable-ASCII service name; defaults to maskura-gateway.
OTEL_RESOURCE_ATTRIBUTESComma-separated key=value pairs restricted to service.namespace, service.instance.id, deployment.environment.name, cloud.provider, cloud.region, and cloud.availability_zone. Reserved or unknown keys, duplicates, and unbounded or non-printable values fail startup.
OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARGalways_on, always_off, traceidratio, parentbased_always_on, parentbased_always_off, or parentbased_traceidratio; ratio in (0, 1]. Default parentbased_traceidratio with ratio 0.1.
OTEL_BSP_MAX_QUEUE_SIZE, OTEL_BSP_SCHEDULE_DELAY, OTEL_BSP_MAX_EXPORT_BATCH_SIZE, OTEL_BSP_EXPORT_TIMEOUTBounded span batch queue, delay, batch size, and export timeout. Batch size cannot exceed the queue.
OTEL_BLRP_MAX_QUEUE_SIZE, OTEL_BLRP_SCHEDULE_DELAY, OTEL_BLRP_MAX_EXPORT_BATCH_SIZE, OTEL_BLRP_EXPORT_TIMEOUTThe same bounds for the log batch processor.
OTEL_METRIC_EXPORT_INTERVAL, OTEL_METRIC_EXPORT_TIMEOUTMetric export interval (1000–3600000 ms, default 60000) and timeout (1–300000 ms, default 10000).

Malformed explicit configuration fails startup with a fixed message that never echoes the offending endpoint, header, or resource value. Exported attributes, logs, and metrics are limited to the allowlist in Security §13.1. On SIGTERM or Ctrl-C the gateway stops accepting requests, drains in-flight connections for at most 30 seconds, then flushes providers for at most 10 seconds.

Environment-only values

These values are deliberately absent from the TOML schema. Do not put them in maskura.toml; strict parsing rejects secret-like keys rather than accepting or ignoring them.

Environment variablePurpose and default/behavior
MASKURA_CONFIGSelects the config file when --config is absent. It is not a TOML field.
DATABASE_URLPostgres connection string for durable keys, journal, managed metadata, and hosted multipart state. Its presence also prevents automatic local-appliance mode.
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEYStatic credentials for S3_ENDPOINT; AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are fallbacks. With no static pair, the AWS default credential provider chain is used.
SUPABASE_JWT_SECRETDashboard JWT verification secret. Unset by default.
SUPABASE_ANON_KEYSupabase public/anonymous client key. Defaults to the local Supabase development value.
MASKURA_SECRET_KEKBase64-encoded 32-byte local key-encryption key. Without it, non-local OSS wrapping is ephemeral; automatic local storage persists its own root wrapping key.
MASKURA_SERVICE_BUCKETSSemicolon-separated service-storage backends. Each backend is pipe-separated as `provider
MASKURA_MULTIPART_STAGING_ACCESS_KEY_ID, MASKURA_MULTIPART_STAGING_SECRET_ACCESS_KEYRequired credential pair for hosted multipart staging.
MASKURA_BOOTSTRAP_SECRETSecret paired with keys.bootstrap_key / MASKURA_BOOTSTRAP_KEY; both must be present or absent.
MASKURA_ROOT_USER, MASKURA_ROOT_PASSWORDOptional local-appliance root access key and secret; set both or neither. Otherwise Maskura generates and displays a credential once. On restart, configured values must match the saved credential or Maskura will not start.

Maskura security

This document describes the security model of the Maskura Gateway as it exists in the current streaming architecture (Phases 0–12): who authenticates, how API keys are handled at rest and in use, how objects are transformed and staged, what the trust boundaries are, what a production deployment must configure, and what Maskura explicitly does not guarantee. Anything not described here is not a guarantee.

For the vulnerability reporting process, see SECURITY.md.


1. Overview & trust boundaries

                        ┌─────────────────────────────────────────────────────┐
  Dashboard (browser)   │              Maskura Gateway                       │
   Supabase Auth/JWT ───▶  /dashboard/*  (JWT-validated)                      │
                        │                                                     │
  Maskura SDK / CLI ────▶  S3 data plane  ── Wasm pipeline ──▶ storage        │
  native S3 tools ──────▶  (SigV4 / API key)  (streaming, fail-closed)        │
                        │  (durable journal + staging for transactional paths)│
                        └─────────────────────────────────────────────────────┘
                                                                   (S3/R2/B2/MinIO)

Trust boundaries:

  1. Identity — the data plane is authenticated by Maskura API keys. Requests are accepted only when the SigV4 signature or the SDK header secret verifies against a registered key; the dashboard is authenticated by Supabase Auth (JWT). AUTH_DISABLED=true bypasses auth and is a local-development-only mode.
  2. Data in transit — HTTPS is required in production. SigV4 signatures are computed over-the-wire but the secret-bearing headers must not traverse plaintext HTTP.
  3. Data at rest — objects are stored on the configured backend after the plugin pipeline has run. Staged object bytes are encrypted before they touch the staging artifact store or disk. API key secrets are never stored in plaintext (see §4).
  4. The gateway is a router — it holds backend credentials only to re-sign requests to the configured backend. The zero-trust presigned-URL path forwards without storing any backend credential at all.

Operator trust and the limits of signed policy

The gateway and its operator remain trusted during processing. Maskura does not provide end-to-end execution attestation or per-operation computation proofs. An operator controlling the deployed binary, host, or local trust store can bypass the gateway’s checks; those checks do not independently constrain that operator. Wasm isolation constrains plugins, not the administrator of the runtime.

Signed TOML configuration provides artifact authenticity relative to configured trust roots. It does not prove that a particular read/write used that artifact. The contract for customer-signed policy approvals is approval evidence plus fail-closed enforcement by the trusted gateway, not operator-proof execution.

Policy approval has three deployment states that must not be conflated:

  1. Shipped in the public engine — the request-path PolicyGate seam (crates/gateway/src/policy_gate.rs), inert by default and behaviorally identical to OSS when no gate is configured; typed policy errors surfaced as the S3 XML <Code> element (policy.denied, policy.unprovisioned, policy.expired, policy.tampered; HTTP 403); and verdict consumption: a bound verdict is consumed at storage execution (the request-time destination is re-verified against the frozen binding before commit; drift answers policy.denied), staged multipart uploads persist their create-time verdict and continue under it across envelope rotation/expiry with complete-time limits composed as min(create-time, current), and frozen verdicts are schema-versioned with unknown future versions failing closed.
  2. The hosted enforcement gate — deploying now, per workspace, behind a policy-enforcement state (inactive = inert; enforced = fail-closed policy.unprovisioned without a valid customer approval). For v1, managed storage is the only enforced destination mode; concrete/BYO destinations fail closed at provisioning until their destination wiring lands.
  3. Future enrollment/cutover — no workspace is enforced today; flipping a workspace to enforced is a separate enrollment step that shipping the gate does not authorize.

Approval evidence is versioned canonical-CBOR (a sorted-JSON-keys convention, explicitly not RFC 8949 canonical) ReceiptBody/Checkpoint v2 with SHA-256 digests, carried by WebAuthn ASN.1 DER ES256 assertions bound to the complete receipt digest. Recovery after loss of all authorized keys is a new independently pinned root recorded as a marked discontinuity; a reset never signs an old-chain receipt, and a server-served trust bundle is never an independent pin.

Plaintext is available transiently to the gateway while transformations execute. Client-held decryption keys protect encrypted output; they do not establish confidentiality from the gateway during processing. TLS and SigV4 protect their transport/authentication boundaries, not the identity of the running code. Signed approval artifacts do not prove absence of extra plaintext copies, complete PII detection, or durable storage of an output.

Independent approval verification requires independently trusted signer keys. A server-only hash chain cannot establish complete/latest history or exclude split views; consistency checks need retained checkpoints and must state their freshness limits. Build-provenance attestations and integration-test evidence are separate from execution attestation. See ADR 0022.

Customer verification is available: maskura policy verify checks exported approval evidence (EvidenceBundle v1) offline against customer-retained checkpoints and a customer-pinned trust root (docs/policy-verification.md). The claim is customer-verifiable approval history against customer-retained checkpoints — not execution attestation and not witness-verified. A trust store pin is written only from customer-controlled input (an imported customer artifact or a manual fingerprint); a server-fetched trust bundle can never establish a pin, and a trust reset renders as a recorded discontinuity accepted only explicitly. This changes nothing about the deployment states above: the enforcement rollout state is unchanged.

2. Authentication

Dashboard

  • Supabase Auth (GoTrue); the gateway validates the access token JWT (SUPABASE_JWT_SECRET, with audience validation) for /dashboard/* routes.
  • Local/demo mode (AUTH_DISABLED=true) bypasses auth entirely — do not use that flag outside local development.

S3 data plane — API keys

A Maskura API key is a pair maskura_<32-hex> (access key ID) + maskura_secret_<32-hex> (secret), revealed once at creation. Two authentication paths:

  1. Maskura SDK header path — the client sends the plaintext secret in x-maskura-access-key / x-maskura-secret-key headers or Authorization: Bearer <access_key>:<secret>. The gateway compares sha256(secret) against the stored secret_hash. Requires TLS.
  2. Native S3 tools (SigV4) — the client signs each request with AWS SigV4 using its Maskura key. The gateway recomputes the signature and rejects requests whose signature does not match the stored secret.

The x-maskura-* customer headers are the only supported names. Reserved metering, operation, and usage headers are rejected. Credential prefixes are maskura_, maskura_secret_, and maskura_mcp_. The 0.7 namespace cut intentionally invalidates credentials issued under the pre-Maskura prefixes.

SigV4 verification

Verification is header-first and pre-body: parsing, credential-scope checks, signed-header validation, timestamp checks, and canonical seed signature verification all complete before a request body is polled.

  • Header auth — Authorization: AWS4-HMAC-SHA256 Credential=… with x-amz-date and x-amz-content-sha256 (mandatory for header auth).
  • Query auth (presigned) — X-Amz-* query parameters; presigned requests without an explicit x-amz-content-sha256 header default to UNSIGNED-PAYLOAD, which is only accepted over trusted TLS.
  • Signed-header integrity — host is always signed, and header auth also requires signed x-amz-date and x-amz-content-sha256. In every SigV4 mode, every present x-amz-* header except x-amz-user-agent and x-amz-checksum-mode, plus each present request-semantic header (x-maskura-storage-mode, x-maskura-backend-url, x-maskura-process, x-maskura-stable-fields, x-maskura-encrypt-fields, content-type, content-encoding, and content-md5, plus their exact legacy aliases), must appear in SignedHeaders, occur exactly once, contain valid UTF-8, and already equal AWS SigV4 TrimAll form: no leading or trailing SP/HTAB and every internal SP/HTAB run collapsed to one ASCII space. The gateway rejects rather than rewrites noncanonical values, including comma-equivalent duplicates, so metadata and tagging consumers observe the same sole canonical value that was signed. The two exact exceptions are also exempt from duplicate and canonical value enforcement because the pinned aws-sigv4 presigner excludes them. x-amz-checksum-mode is read-only response-integrity negotiation, not a storage-selection or processing semantic. Optional headers may be absent, so a normal host-only presigned GET remains valid; violations receive the generic signature-mismatch response before the request body is read.
  • Scope validation — region (MASKURA_SIGV4_REGION, default us-east-1), service (s3), and terminator (aws4_request) are enforced; the request timestamp must fall within the clock-skew window (15 minutes) and presigned URLs cannot exceed 7 days.
  • Payload integrity — the request body is verified against the declared payload hash and, for aws-chunked streaming, each chunk signature and the x-amz-trailer-declared checksum trailer are verified incrementally with constant-time comparisons. Supported modes: UNSIGNED-PAYLOAD, STREAMING-AWS4-HMAC-SHA256-PAYLOAD, STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER, STREAMING-UNSIGNED-PAYLOAD-TRAILER, and a raw SHA-256 payload hash. Seed verification hashes the declared x-amz-content-sha256 value exactly as signed: streaming and unsigned tokens are case-sensitive and are not case-folded.
  • Checksums — x-amz-checksum-* (CRC32, CRC32C, CRC64NVME, SHA-1, SHA-256) declared in headers or trailers are verified against the decoded body. Conflicting declarations, missing trailer declarations, duplicate trailers, trailing data after the final frame, and mismatched checksums are rejected. Decoded length must match x-amz-decoded-content-length.
  • Signing-key cache — a bounded, TTL-based cache stores only derived signing keys and secret fingerprints; the plaintext secret exists in memory only transiently during verification and is never logged or persisted.
  • SigV4 verification is skipped only in AUTH_DISABLED local mode.

3. API key handling at rest

Each key stores two artifacts:

  • secret_hash — sha256(secret) for the SDK header path. Not reversible.
  • secret_encrypted — an envelope: v2:{base64(wrapped_dek)}:{base64(nonce)}:{base64(ciphertext+tag)} where the secret is AES-256-GCM encrypted under a fresh, per-key 256-bit data key (DEK), and the DEK is wrapped by a KeyWrapping implementation (crates/gateway/src/key_cipher.rs). v2 envelopes bind the API key identity as AES-GCM additional authenticated data (AAD), preventing an encrypted secret from being moved to another key. v1 envelopes are accepted for legacy keys.

The plaintext secret exists only in the create-key HTTP response and, transiently, in memory during SigV4 verification. It is never logged or persisted.

Credential mutations are bounded before persistence. API-key and MCP labels are trimmed, non-empty, free of control characters, and at most 128 UTF-8 bytes. Non-zero API-key and MCP lifetimes are at most one year (0 means no expiry). Encryption public keys are at most 16 KiB and must be a MASKURA HYBRID PUBLIC KEY PEM carrying an X25519 public key and an ML-KEM-768 encapsulation key, matching the format consumed by the envelope-encryption filter. Credential JSON endpoints also have route-specific body limits; oversized requests receive 413.

Credential creation returns plaintext only after the repository has committed the new credential. File-backed mutations remain hidden from concurrent readers until snapshot persistence succeeds, and failed mutations restore the prior state before readers resume. Repository failures are reported as service unavailable; they are never treated as a missing credential, an empty list, or an authentication denial.

The file-backed repository writes and synchronizes a permission-restricted temporary snapshot, then atomically renames it as the mutation commit boundary. It attempts to synchronize the parent directory after rename; failure is warned but cannot make the already-published snapshot uncommitted. This provides committed atomic visibility, not an absolute power-loss durability guarantee. Missing snapshots start empty; unreadable or invalid snapshots stop startup instead of being replaced. This repository is a single gateway-instance abstraction and does not provide multi-process locking.

Key-wrapping providers

ProviderDurabilityWhen to use
LocalKeyWrapping (MASKURA_SECRET_KEK)Durable (operator-provided 32-byte KEK)Local dev and self-host with a managed KEK
Ephemeral (no KEK configured)Not durable (random in-memory KEK, lost on restart)Local dev only
KMS/Vault-backed wrapping (injected)DurableProduction

build_state accepts an injected Arc<dyn KeyWrapping>. The OSS self-host binary resolves MASKURA_SECRET_KEK / ephemeral via key_cipher::default_wrapping(); a KMS/Vault-backed wrapper can be supplied by an embedding deployment without changing the engine. is_durable() is the load-bearing flag: durable staging fails closed when the active wrapping is not durable (see §7), because a lost DEK would permanently strand staged tenant data.

SecretCipher::decrypt retains its public compatibility behavior and collapses invalid data or wrapping failures to None. Credential repositories use decrypt_result instead, allowing operational KMS/Vault failures to become a generic service-unavailable response rather than an authentication denial.

Non-local deployments must use a durable KMS/Vault-backed wrapping. Running with auth enabled while the active wrapping is the local KEK or an ephemeral key is not secure for shared/non-local use. The gateway logs a prominent warning at startup in this configuration.

Legacy hash-only keys (created before envelopes existed) cannot verify SigV4 signatures — regenerate them if you need native S3 tools.

4. The plugin pipeline (data transformation)

  • Plugins are Wasm components (wasmtime, Component Model + WIT maskura:filter@0.1.0), 64 MiB aggregate guest memory per object, 10K table entries, 4 memories, 512 KiB max stack. No host imports beyond WASI stdout/stderr; the boundary is byte-in/byte-out.
  • A fresh Store and component instance per object (ADR 0007): no runtime state is shared across objects or tenants. Compiled components may be cached by hash; guest state (linear memory, globals, resources) is strictly per-invocation and dropped when the object completes.
  • Fuel and deadlines — per-call and cumulative fuel budgets (MASKURA_WASM_FUEL), a per-call wall-clock deadline (epoch interruption, default 30s) and an object deadline (default 5 minutes), plus cancellation tokens that interrupt active guest calls.
  • Admission control — streaming sessions run on a bounded worker pool with a capped queue and a guest-memory budget (MemoryAdmission); admission failure surfaces as SlowDown rather than unbounded parallelism.
  • The pipeline is deterministic and runs before data reaches storage on the write path: redaction replaces PII, encryption (per-field hybrid X25519 + ML-KEM-768 / AES-256-GCM) keeps the decryption key solely with the client. stable-encrypt (AES-SIV) is opt-in for JOIN keys and derives from the API key secret, never the raw secret.
  • Every authenticated request resolves its user through the WorkspaceStorageRepository to a canonical WorkspaceId. Workspace IDs are opaque, used unchanged, and limited to 1–128 ASCII characters from [A-Za-z0-9._-]; a repository may map multiple users to one workspace. Resolution failure fails closed.
  • The canonical workspace ID, not the user ID, scopes backend configuration, managed placement, multipart identities and quotas, and usage metering. Object sessions remain isolated per invocation.

5. Transformed reads — fail-closed disclosure rules

Read-time processing (x-maskura-process: read) runs the pipeline on the way out: the object in storage is unchanged, and the caller receives only the transformed projection. This is the agent-safe-read path. The guarantees are deliberately strict:

  • Preflight from stored metadata. A transformed read performs a metadata preflight against the stored object before any source body is consumed. The preflight validates Content-Type against a known format (JSON/JSONL/CSV/TSV/text), rejects Range, partNumber, non-identity Content-Encoding, and unknown mandatory formats, and requires stored metadata. Presigned-HTTP backends are rejected for transformed reads because they cannot provide a safe metadata preflight.
  • Version/ETag binding. The source GET must match the preflight: either an immutable version ID equality or a strong ETag equality (weak ETags are not accepted). If the source metadata changed between preflight and GET, the request is rejected — never silently served.
  • No raw fallback. A pipeline error, decoder error, or staging failure fails the request closed. There is no fallback to raw bytes under any condition.
  • Prefix-safe direct streaming only. When every component in the pipeline snapshot is marked prefix-safe for read (operator-declared via MASKURA_PREFIX_SAFE_COMPONENT_HASHES at process start — imported components are unsafe by default and the capability is immutable per component hash), the transformed output is streamed directly with bounded backpressure and no disk spool. Actual source/output bytes, fuel, duration, and component evidence are settled only after successful pipeline completion. EOF is withheld during bounded exact settlement retries. Cancellation before disclosure releases the reservation; cancellation, pipeline failure, or exhausted settlement after disclosure leaves the reservation for bounded control-plane recovery and never records successful customer usage.
  • Encrypted read spool otherwise. Any snapshot containing an unsafe component is rejected unless MASKURA_TRANSFORMED_READ_SPOOL=encrypted is set. The transformed output is then written to a disk spool in independently authenticated AES-256-GCM chunks under a key that lives only in the request task (a stale spool file is unreadable after a restart). The spool quota is reserved before source disclosure, file permissions are 0600, the response body is streamed from the spool only after the full representation is written, and a truncated or corrupted spool terminates the response body. Dropping the response body cancels the replay and releases the reservation.
  • No HEAD on transformed reads — HEAD is rejected until transformed metadata is available.
  • Transformed reads require stored, version-bound metadata and work with S3, managed storage, and in-memory backends (in local mode). Presigned backend URLs remain raw-only.
  • MASKURA_STREAMING_READ_MODE gates the path: off (default) rejects transformed reads entirely; passthrough enables only raw streaming; transformed enables this path.

6. Storage backends

  • Presigned URL proxy (x-maskura-backend-url) — the user generates a presigned URL with their own cloud SDK; Maskura filters and forwards. No backend credential is stored. The gateway applies SSRF controls before any request is made (see §10).
  • Per-workspace backend config — managed, s3_compatible with an endpoint, region, and static access credentials, or aws_role (IAM role ARN
    • region + optional external_id) that is assumed via STS per request. Runtime S3-compatible endpoints are governed by WorkspaceEndpointPolicy (see §10), and dashboard reads return only a redacted configuration.
  • Service storage — Maskura-managed multi-cloud buckets (MASKURA_SERVICE_BUCKETS), tenant-namespaced by workspace and optionally backed by authoritative placement metadata (see §9).
  • Resolution contract — an explicit x-maskura-storage-mode: managed request is resolved first, followed by a presigned URL, a per-workspace configuration, and configured service storage as the default. Only explicit single-tenant mode (AUTH_DISABLED=true or MASKURA_SINGLE_TENANT=true) may continue to the global S3_ENDPOINT and then in-memory storage.
  • Multi-tenant fail-closed boundary — startup rejects S3_ENDPOINT and requires non-empty MASKURA_SERVICE_BUCKETS. An unconfigured workspace therefore uses managed storage; workspace repository failures and unavailable explicit or workspace-managed selections never fall through to a process-global backend.

Multipart uploads are not buffered in gateway memory: parts are staged durably and encrypted before any downstream processing (see §7).

7. Multipart staging — durable, encrypted, fenced

MASKURA_MULTIPART_MODE=staged (default reject) enables client multipart uploads through a durable staging subsystem. In standalone local mode, the repository, artifacts, journal, proofs, and wrapping key are all stored beneath the configured FileStore root. Hosted staged mode retains the Postgres and dedicated staging backend requirements described below.

  • Durable encrypted staging. Each part is written to an encrypted artifact (under local .maskura/multipart/ in local mode, or MASKURA_MULTIPART_STAGING_DIR in hosted mode) framed as MSKMP1 magic + JSON header (containing the wrapped DEK, tenant/upload/part identity, and a digest of the multipart snapshot) followed by AES-256-GCM chunks whose AAD binds the header and chunk number. Hosted artifacts use the dedicated Maskura-controlled object store (MASKURA_MULTIPART_STAGING_BUCKET/ENDPOINT/credentials). Local artifacts remain on the FileStore volume and use its persisted root wrapping key.
  • Durability requirement. Hosted staged multipart requires a durable wrapping (KMS/Vault or a configured MASKURA_SECRET_KEK), DATABASE_URL, and complete staging backend configuration. Local staged multipart generates and persists a mode-0600 wrapping key under .maskura/ and uses file-backed repository and journal implementations; it intentionally requires none of those hosted dependencies.
  • Durable quota reservations. Per-workspace and global staging quotas (MASKURA_MULTIPART_STAGING_TENANT_QUOTA_BYTES / _GLOBAL_QUOTA_BYTES) are reserved by the active multipart repository before any body frame is consumed, temp file opened, or artifact created. Hosted reservations use Postgres row locks; local reservations use the locked file-backed event log. Crash-consistency is handled by a pending-outbox (begin/commit/discard) state machine that reconciliation replays.
  • Snapshot binding. The multipart snapshot (metadata, tags, checksum mode, destination, plugin snapshot, limits) is recorded at initiation; completion replays each artifact through a reader that authenticates the envelope identity, snapshot digest, and AEAD tags before any frame is exposed, and verifies the replayed part against the committed ETag/checksum/size.
  • Completion fencing. CompleteMultipartUpload is serialized by a durable completion lease with a fencing token and a request fingerprint. Idempotent retries replay the stored result; a stale worker that loses its lease is fenced (its renewals and any abort are rejected) — a fenced completion returns ServiceUnavailable instead of corrupting state. Takeover is explicit: a new worker acquires the lease with its own token, and the old worker cannot abort.
  • Expiry and reconciliation. Expired uploads are reaped by a background worker (aborting the destination and cleaning staged parts); orphaned artifact files are removed by startup and periodic cleanup; pending outbox entries are reconciled against the artifact store.

8. Transactional writes — journal, atomic commit, reconciliation

Streaming writes (single-part PUT, and staged multipart when MASKURA_MULTIPART_MODE=staged) run through a durable transaction layer:

  • Operation journal. Hosted deployments use the Postgres-backed OperationJournal; standalone local deployments use the file-backed journal under the FileStore root. (DATABASE_URL) records each operation’s state machine: INTENT → OPEN → COMPLETING → COMMITTED, with COMMIT_UNKNOWN and PROVEN_ABORTED for crash recovery. Streaming writes in a non-development deployment require a durable journal; there is no in-memory fallback.
  • Atomic commit. The transformed output is fully verified (decoded length, SHA-256 of the emitted bytes, per-part ETags/checksums) against the expected object before the destination commit is issued. On any failure the sink is aborted and the destination multipart upload is cleaned up.
  • COMMIT_UNKNOWN reconciliation. When a completion outcome is ambiguous (the request failed after complete was issued), the operation transitions to COMMIT_UNKNOWN. Request-scoped cleanup may reconcile only that exact operation using the backend bound to the request. Per-user operations that cannot be safely reprobed remain pending for an external reconciler with the original credentials; one request never scans or probes another tenant’s operations.
  • Launch sink boundary. Direct S3 output is streamed as a multipart upload. Managed and presigned-HTTP streaming writes are rejected before request-body polling or multipart staging until their commit evidence can share the same authorization identity and reconciliation guarantees.

9. Managed replication — authority and repair fencing

MASKURA_MANAGED_STREAMING_MODE (default off; observe/enforce require MASKURA_MANAGED_STREAMING_TRANSACTIONAL=true and a durable repository) adds authoritative metadata over the consistent-hash placement:

  • Launch admission. Transactional enforce mode accepts only exact lowercase b2 and aws provider kinds. Every backend must use HTTPS, have an explicit stable provider instance/account identity and positive credential epoch, have a unique stable backend ID, and participate in a positive static placement policy. The complete pool is checked before its placement policy is recorded and again before each managed streaming write begins.
  • Placement. Deterministic rendezvous hashing (versioned, MASKURA_MANAGED_PLACEMENT_VERSION) selects a primary and one replica backend per logical object, independent of backend input order.
  • Authority. An ObjectAuthority row records generation, digest, size, metadata, and primary/replica copy status with compare-and-swap semantics (cas_version); concurrent writers cannot silently overwrite authority.
  • Repair fencing. Replication repairs are durable records claimed with a lease owner and token; renewal, completion (compare-and-swap), and failure are all token-checked, so a stale worker cannot apply or duplicate a repair after its lease is taken over. validate_mode refuses to turn managed mode off after authority rows exist, and refuses observe/enforce without DATABASE_URL.

10. Outbound requests — SSRF, DNS, redirect, expiry, address pinning

Presigned-URL handling (PresignedHttpPolicy) applies the following before a single byte is fetched or sent:

  • Host allowlist — the URL host must be in MASKURA_PRESIGNED_HTTP_ALLOWLIST (supports *.suffix wildcards).
  • Scheme — HTTPS is required. MASKURA_PRESIGNED_HTTP_ALLOW_HTTP=true permits HTTP only for an explicit presigned source GET; presigned PUT and DELETE destinations remain HTTPS-only.
  • No userinfo or fragments, and the scheme must be supported.
  • Expiry validation — the URL must carry an explicit expiry (X-Amz-Date+X-Amz-Expires or Expires) with at least MASKURA_PRESIGNED_HTTP_MIN_VALIDITY_SECS (default 30s) remaining.
  • DNS + address pinning — the host is resolved at request time; any non-public address range rejects the request unless the host is in MASKURA_PRESIGNED_HTTP_PRIVATE_ALLOWLIST. The resolved address is pinned on the client (no re-resolution) and proxying is disabled.
  • No redirects — redirects are disabled at the client and redirect responses are rejected outright on reads.

Persisted S3-compatible workspace endpoints use a separate WorkspaceEndpointPolicy; presigned URL policy and expiry rules are not reused:

  • In multi-tenant mode HTTPS is mandatory, URLs cannot contain userinfo, query, or fragment components, and the host must exactly match or be a strict dot-boundary *.suffix entry in MASKURA_WORKSPACE_ENDPOINT_ALLOWLIST.
  • DNS is revalidated before every per-workspace AWS SDK client is constructed. Empty answers, private/reserved addresses, mixed public/private answers, IP-literal allowlist bypasses, and IPv4-mapped private IPv6 are rejected.
  • The per-workspace AWS SDK connector explicitly disables proxies and does not use browser-style redirect following. It performs its own DNS lookup after validation rather than using the validated addresses, so the multi-tenant allowlist is a trusted-provider boundary: operators must allow only provider domains whose DNS tenants cannot control or rebind.
  • Every gateway AWS SDK S3 client is configured for one SDK attempt. Journaled and spooled write transactions retain their own explicit bounded retry budget (currently three attempts), so retries remain visible to transaction evidence, fencing, and reconciliation rather than occurring inside the SDK.
  • HTTP or private addresses are accepted only in explicit single-tenant mode. Private destinations additionally require an exact operator entry in MASKURA_WORKSPACE_ENDPOINT_PRIVATE_ALLOWLIST; wildcard private exceptions are invalid.
  • Public builds provide no common-provider allowlist defaults. Deployments must choose the provider domains they trust.
  • Endpoint-family recognition and streaming authority are separate. The public engine recognizes only explicit AWS S3 global, known regional, legacy regional, external-1, dualstack, and FIPS data-plane forms, plus GCS XML/HMAC, B2 S3, R2, DigitalOcean Spaces, and Wasabi origin grammars. Arbitrary s3-* labels and AWS website, control, Object Lambda, Outposts, accelerate, access-point, and virtual-hosted bucket forms are not accepted. Recognition never grants capabilities.
  • Hosted direct streaming requires an adapter-supplied immutable config version and operator capability/permission attestation. B2 additionally requires version listing/deletion and exact-version recovery. Missing attestations or legacy repository implementations fail closed.
  • Before body polling (including Avro OCF) or provider mutation, the adapter must atomically acquire a database routing lease bound to operation ID, config version, attestation, and routing epoch. A background heartbeat covers body processing, and every provider future is directly raced against renewal after an immediate fence assertion. Fence loss drops that future and forbids later probes, aborts, or mutations. Only committed or proven-aborted operations release the lease. Config transitions and version retirement must conflict while a lease or nonterminal journal row is open.
  • Journal rows store only opaque version/attestation IDs and routing lease/fence values, never credentials. Startup and periodic reconcilers call reconcile_workspace_streaming_operation. Nonterminal recovery claims the journal row first, then performs an expired-only CAS over the persisted lease ID/token/config/attestation/epoch. The private adapter advances both the route fence and journal binding in one transaction before historical credentials are loaded and the same client is reconstructed. There is no fallback to current credentials and an active route lease is never stolen.
  • Journal terminal state precedes route settlement. This ordering preserves the authoritative provider outcome across crashes. The startup/periodic pass also visits workspace-bound COMMITTED and PROVEN_ABORTED rows and idempotently settles their exact lease after atomically verifying the matching journal outcome, closing the crash window between journal commit and request cleanup.
  • Private implementation boundary: the SaaS repository owns encrypted, immutable config-version retention, provider conformance attestation, atomic lease/config-transition transactions, lease recovery, and scheduling the public reconciliation hook. Until it implements every new repository method, hosted PerUserS3 direct streaming remains disabled by rejecting defaults.
  • Process-global S3_ENDPOINT storage still requires explicit MASKURA_STREAMING_S3_PROVIDER. A non-durable journal is accepted only in a debug build with both auth disabled and explicit single-tenant mode; release and hosted/global production paths require a durable journal.

11. Cancellation and cleanup

  • Cancellation. Every streaming operation carries a cancellation token that propagates through the Wasm pipeline, the source body, the sink, and spool replays. Dropping a response body cancels the source and pipeline; a cancelled guest call is interrupted via the epoch engine and surfaced as WASM_CANCELLED. Request aborts never issue destination aborts from a stale worker (fencing checks first).
  • Cleanup/reconciliation workers. Startup and periodic jobs remove stale compatibility spool files, orphaned encrypted multipart spool files, reconcile pending staging outboxes against the artifact store, reap expired multipart uploads, and reconcile managed repairs. Direct COMMIT_UNKNOWN operations require exact-operation reconciliation with the immutable backend config version and routing fence originally bound to the request. The same pass revisits terminal workspace operations until route settlement succeeds; terminal settlement is idempotent and performs no provider request.

12. Bounded parsing and memory

  • The legacy 16 MiB whole-object buffering path was removed (Phase 12): there is no whole-object memory buffer on the data plane. The gateway runs fixed-RSS streaming: source frames are bounded by MASKURA_SOURCE_MAX_FRAME_BYTES and decoded object bytes by MASKURA_MAX_OBJECT_BYTES; per-record decoder limits apply; and MASKURA_LEGACY_MAX_OBJECT_BYTES is no longer load-bearing.
  • CompleteMultipartUpload XML is capped at 1 MiB and parsed by a strict grammar parser (no general XML resolver): DTDs and entities are rejected before tokenization, parts must be sorted and unique, and part ETags/checksums are validated against staged parts.
  • aws-chunked framing is decoded with fixed-size state and explicit limits; oversized frames, duplicate trailers, and trailing data are rejected.
  • Wasm guest memory is capped per object (64 MiB aggregate) with a bounded worker pool and memory budget (see §4).

13. Logging prohibitions

The gateway must never log:

  • Object bytes or transformed payloads (neither plaintext nor ciphertext object content),
  • Staging keys or DEKs (wrapped or unwrapped),
  • Credentials — API key secrets, backend access keys, KEKs, or tokens,
  • Customer identifiers — bucket names, object keys, user IDs, credential identifiers, or tenant-controlled labels,
  • Backend endpoint URLs containing query data (these are rejected before persistence or client construction),
  • Signed URLs (presigned URLs are bearer credentials),
  • Staging artifact ciphertext or key material,
  • Raw database, provider, filesystem, plugin, or transport errors on request and reconciliation paths; these can contain customer identifiers, local paths, schema details, or upstream response bodies.

Operational logs use server-generated operation or receipt IDs, bounded counts, and reviewed low-cardinality categories. Client-facing internal and service-unavailable responses use fixed protocol-appropriate messages rather than raw error text. Local startup diagnostics may name operator-configured filesystem and plugin paths, but request handling never logs customer object identity or raw failure detail.

Local appliance credentials. When no hosted or external storage is configured, the gateway generates a root access key and secret and logs them on the first start. Later starts reuse the saved credential without logging it. This is the only case in which Maskura logs a credential, and it applies only to the single-node local appliance, not hosted or multi-tenant deployments. See ADR 0019.

13.1 Exported telemetry

The logging prohibitions above apply equally to records exported over OTLP. The agent-builder telemetry increment (HTTP request server span, the http.server.request.completed log, and the maskura.http.server.* metrics) is restricted to a fixed allowlist and can never carry customer identifiers.

  • The only span attributes are maskura.request.id, http.request.method, http.route, http.response.status_code, maskura.http.status_class, and maskura.http.outcome. http.route is the router’s matched template (for example /v1/objects/{*key} or unmatched), never a concrete path or query.
  • The completion log carries exactly request_id, method, route, status_class, outcome, and duration_ms, plus trace/span IDs from the server-owned span. It has one fixed event name and no message or error text.
  • Metrics use only normalized method, matched route template, status class, and outcome. Request IDs and tenant, object, operation, receipt, or exception identity are forbidden.
  • The resource carries service.name, service.version, maskura.process.role, and only the allowlisted optional attributes (service.namespace, service.instance.id, deployment.environment.name, cloud.provider, cloud.region, cloud.availability_zone). Other resource keys fail startup.
  • The trace layer rejects every event and every span other than the exact request-span callsite, and it contributes no target, thread, source-location, level, exception, or error-record attributes.
  • A sanitizing HTTP client maps collector transport failures to an opaque error and strips collector response bodies, so no endpoint, header, URL, or collector body can be reflected into a local or exported record. OTEL and HTTP-client dependency targets are suppressed from local output entirely, including WARN and ERROR.
  • Export is disabled unless an OTLP endpoint is configured. Collector DNS, connection, timeout, HTTP, or decoding failures occur in background batch workers and never affect request handling, health, readiness, billing, or reconciliation. Enabling a production collector is a separate operations decision and is not implied by this code.

See ADR 0020 for the trust boundary and signal schemas.

14. Deployment responsibilities

You must handle the following outside the Maskura container:

Non-secret gateway settings may be set by strict TOML keys or their documented environment overrides; environment values take precedence. See the configuration reference for the complete schema. Secrets and other environment-only settings, including MASKURA_SECRET_KEK, MASKURA_SERVICE_BUCKETS, backend credentials, DATABASE_URL, and bootstrap secrets, are not TOML keys. Settings that support both TOML and environment variables, including MASKURA_WORKSPACE_ENDPOINT_*, MASKURA_PRESIGNED_HTTP_*, MASKURA_SIGV4_*, MASKURA_MANAGED_*, and non-secret MASKURA_MULTIPART_STAGING_* values, use those names for their environment overrides.

  • TLS termination — place the gateway behind a TLS-terminating proxy (platform load balancer, ingress, or reverse proxy). SigV4 is signed over-the-wire, but the SDK header path sends secrets in headers, so HTTPS is required for the secret-bearing headers. Set MASKURA_SIGV4_TRUSTED_TLS=true if your proxy terminates TLS and the gateway sees HTTP.
  • Trusted proxy — restrict access to the gateway to the trusted proxy (or bind the listener accordingly); do not expose it directly on the internet without TLS.
  • KMS/Vault readiness — configure a durable KeyWrapping (KMS or Vault) for any non-local deployment. MASKURA_SECRET_KEK is durable but operator-managed plaintext; the ephemeral wrapper loses all wrapped secrets on restart. The gateway will not start durable multipart staging without durable key wrapping.
  • Durable journal / staging dependencies — standalone local mode uses one mounted MASKURA_LOCAL_STORAGE_DIR and automatically places its file-backed repository, journal, proofs, artifacts, and wrapping key beneath that root; it must run as one active process per root. Hosted staged mode requires DATABASE_URL plus the complete MASKURA_MULTIPART_STAGING_* configuration; managed observe/enforce still requires Postgres and transactional capabilities.
  • Backend lifecycle permissions — the backend credentials Maskura uses for direct/managed streaming must be able to create, abort, and discover multipart uploads, complete uploads, and (for reconciliation) perform conditional reads/HEAD. Maskura enables streaming only when the backend can discover and abort incomplete uploads, reconcile completed uploads, and clean up within five minutes.
  • Feature defaults — transformed reads and managed streaming are off/reject by default and must be explicitly enabled: MASKURA_STREAMING_READ_MODE=off, MASKURA_MULTIPART_MODE=reject, MASKURA_MANAGED_STREAMING_MODE=off. Single-part streaming writes are always enabled; staged multipart additionally requires MASKURA_MULTIPART_MODE=staged. Local mode supplies its durable file-backed dependencies from the mounted root; hosted mode requires its Postgres, wrapping, and staging-backend dependencies. If you enable one of these features without its required durable services, Maskura will not start.
  • Outbound credentials — the global S3_ENDPOINT client accepts static S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY and, when those are absent, falls back to the AWS default credential provider chain (EC2 instance profile, ECS task role, EKS IRSA, SSO, OIDC web identity). Per-workspace aws_role backends assume an IAM role via STS: temporary credentials are used once per request, never serialized, and the role ARN plus an optional external_id form the trust boundary (see ADR).
  • Self-host hardening — run the container as non-root; limit egress to the configured backends, KMS/Vault, and Supabase; do not ship MASKURA_KEYS_FILE or keys.json in the image; do not set AUTH_DISABLED in production; put the spool and staging directories on private, capacity-reserved volumes sized for MASKURA_SPOOL_MAX_OBJECT_BYTES/MASKURA_SPOOL_QUOTA_BYTES (including encrypted framing overhead).
  • Dependency/update policy — track releases and apply security fixes promptly; run just deny (cargo-deny) and just audit (cargo-audit) in your pipeline and keep the pinned toolchain (Rust 1.97.0) and Wasmtime version current. Only the latest stable minor is supported (see SECURITY.md).

15. Non-guarantees — the data-plane vs. provider distinction

Maskura is a data-plane processing gateway, not a storage provider. Maskura does not secure the third-party storage it is pointed at:

  • Maskura does not control bucket policies, server-side encryption, access logs, retention, replication, or deletes on the destination. Configure those on the destination itself.
  • Maskura forwards to the destination exactly the transformed representation and relies on the destination’s credentials/permissions for access control. Misconfigured destination credentials, world-readable buckets, or missing destination-side encryption are outside Maskura’s control and are not Maskura vulnerabilities.
  • Maskura does not secure the identity provider (Supabase) or the email/analytics services used by the dashboard.
  • Client-side decryption keys (for per-field encryption) and stable-encrypt keys are held by the client; Maskura never holds them.

ADR 0001: WebAssembly Component Model and WIT Contract

  • Status: Superseded by ADR-0016
  • Date: 2026-08-09

Context

Maskura needs a contract between the gateway host and tenant-supplied filter plugins. The options considered:

  1. Core Wasm C ABI (pointer + length): Host and guest exchange raw linear memory pointers. Fragile, no type safety, manual memory management on the guest side.
  2. WASI command model (wasm32-wasip1): Treats each invocation as a separate process with stdin/stdout. Can’t maintain state across records within a single object stream.
  3. WebAssembly Component Model with WIT: Typed interface definitions, stateful sessions, automatic memory management via list<u8>.

Decision

Use the Component Model and a versioned WIT world (package maskura:filter@0.1.0). The guest exports begin, transform, and finish functions with typed parameters. Stateful per-object sessions allow filters to accumulate context across records.

Consequences

  • Host side uses wasmtime’s component model bindgen for typed function calls.
  • Guest side uses wit-bindgen for idiomatic Rust trait implementations.
  • Shared WIT file is the single source of truth for the data-plane contract.
  • Fresh Store per object provides strong isolation between requests.

ADR 0002: AWS Nitro Enclaves, TLS-in-Enclave, us-east-1

  • Status: Superseded by ADR-0022
  • Date: 2026-08-09

Context

Maskura’s core value proposition is operator-resistant confidentiality: plaintext and secrets must be hidden from Maskura operators and cloud host administrators. The trusted execution environment must:

  • Not expose plaintext to the parent EC2 instance.
  • Terminate TLS inside the enclave (parent is an opaque byte relay).
  • Support remote attestation for customer verification.
  • Be available in a region with reasonable latency for US customers.

Decision

Use AWS Nitro Enclaves with TLS termination inside the enclave (ACM for Nitro Enclaves with NGINX/PKCS#11). Deploy in us-east-1 initially. Parent EC2 runs a minimal TCP/vsock relay that never sees plaintext. KMS enforces PCR-based attestation conditions before releasing gateway secrets.

Consequences

  • No Cloudflare proxying for data-plane traffic (would expose plaintext outside the enclave).
  • ARM Graviton instances (m7g.xlarge) preferred for cost/performance; validate arm64 Wasmtime before provisioning.
  • Local development uses a virtualized parent/enclave pair with clearly marked dev attestation roots.
  • Documentation must record PCRs and release manifests for customer verification via maskura.

ADR 0003: Canonical CBOR and Ed25519 Policy Manifests

  • Status: Accepted
  • Date: 2026-08-09

Context

Trust-boundary clarification (2026-09-23): ADR 0022 narrows the operator-resistance language below to approval of signed artifacts, not proof of their execution. The gateway/operator remains trusted. This original design record is not evidence that the hosted envelope, publish-approval, or recovery flows have shipped; existing signed pipeline configuration is covered by ADR 0021.

Tenant-defined pipeline configuration (destinations, routes, filters, limits) must be verifiably authentic. Maskura operators must not silently change policy. The signed manifest is the customer’s attestable statement of intent.

Options considered:

  • JSON with detached signature: Non-deterministic whitespace and key ordering make verification fragile.
  • Protobuf/FlatBuffers: Binary formats with deterministic encoding, but separate schema tooling adds complexity.
  • Canonical CBOR with Ed25519: Deterministic map ordering, compact binary encoding, and well-supported Rust libraries.

Decision

Use canonical CBOR encoding with lexicographically sorted map keys and Ed25519 signatures. The manifest body is encoded deterministically, then signed. The resulting SignedManifest carries the canonical body bytes, signer ID, and signature. Verification validates the signature against trust roots and checks expiry/version monotonicity.

Consequences

  • Trust roots are tenant-managed Ed25519 public keys provisioned during onboarding.
  • Policy updates require a new signature; the dashboard can draft but maskura signs and activates.
  • Manifests have short expiry; monotonic version warnings on stale manifests discourage rollback.
  • Implementation uses ciborium crate with BTreeMap-based canonical serialization.

ADR 0004: Postgres Relational Source of Truth

  • Status: Accepted
  • Date: 2026-08-09

Context

Maskura needs a control-plane data store for users, workspaces, destinations, policies, API keys, usage records, and audit events. Options:

  • Cloudflare D1: Global SQLite but limited relational constraints and per-request latency variance.
  • Durable Objects: Good for stateful coordination but not a relational store.
  • KV/Queues: Not suitable for relational queries, authorization, or billing integrity.
  • Supabase Postgres: Full relational model, RLS for multi-tenant isolation, authentication integration.

Decision

Use Supabase Postgres as the sole relational business data store. No JSONB for application state; only for opaque payloads (Paddle webhooks, signed manifests, audit details). Store money as integer minor units, byte usage as BIGINT.

Consequences

  • RLS enforces workspace isolation; service-role credentials exist only in server-side Worker secrets.
  • Migration workflow is expand/contract, backward compatible across one version.
  • Avoid D1, Durable Objects, KV, Workflows, and Containers until a demonstrated need exists.
  • Database dependency for deployments means the local stack includes Supabase CLI for testing.

ADR 0005: Record Boundaries vs Transport Chunks

  • Status: Accepted
  • Date: 2026-08-09

Context

Input streams arrive in arbitrary byte chunks that may split records, UTF-8 code points, CSV fields, or JSON tokens. PII detection applied independently to each transport chunk would miss patterns spanning chunk boundaries.

Decision

Decouple record boundaries from transport chunks. Format-specific decoders assemble logical records from the byte stream before presenting them to Wasm filters. Record assembly must be UTF-8 code-point safe and handle chunk boundaries across all supported formats (JSONL lines, CSV quoted fields, JSON tokens).

Consequences

  • Every chunk split of the same input must produce identical output and counters. Property tests verify this invariant.
  • Decoder state is per-object; no cross-object state.
  • Record boundaries for text/JSONL are lines; for CSV, quote-aware line splitting; for JSON, the entire document is one record in MVP.
  • Chunk-size invariant property tests are required in CI from Phase 1 onward.

ADR 0006: Dev Attestation Never Production-Valid

  • Status: Superseded by ADR-0022
  • Date: 2026-08-09

Context

The local development environment simulates the Nitro Enclave topology using Docker containers. It must be impossible to accidentally trust a dev attestation as production-valid.

Decision

The dev attestation provider uses a private CA, clearly marked root certificate, and UNTRUSTED DEVELOPMENT ATTESTATION labels in all tools and outputs. The production attestation chain (AWS Nitro root, KMS PCR conditions) is never present in dev images.

The transport protocol, secret-provider trait, and attestation-provider trait are identical between dev and production, but their implementations are swapped. maskura and the dashboard prominently label dev attestations and refuse to treat dev PCRs as production-approved.

Consequences

  • Docker Compose local stack uses the same gateway binary but different provider implementations.
  • No production signing keys, TLS certificates, or KMS configurations exist in the repository.
  • CI gates prevent dev attestation data from entering production configuration.

ADR 0007: Fresh Wasmtime Store Per Object

  • Status: Accepted
  • Date: 2026-08-09

Context

Wasm modules from different tenants share the same gateway process. A cross-tenant state leak would break the confidentiality guarantee. Wasmtime’s Store holds instance state, including linear memory and mutable globals.

Decision

Create a fresh Store and component instance for each S3 object. Never reuse a Store or instance across objects. Compiled components may be cached by hash (immutable static code), but runtime state (Store, linear memory, globals, resources) is strictly per-invocation.

Consequences

  • Sandbox limits are set per-object: fuel, epoch deadline, memory, stack, hostcall transfer limits.
  • Multiple concurrent objects each get their own Store, instance, and sandbox state.
  • After each object completes, the Store is dropped, releasing all instance memory.
  • No mutable guest state is shared across objects or tenants. Cross-tenant pooling is deferred until zeroization can be demonstrated.
  • Wasmtime’s Store lifetime documentation confirms resources are not released until the Store is dropped, matching the per-object model.

ADR 0008: Keyless AWS credentials, aws_role backends, and bootstrapped gateway keys

  • Status: Accepted
  • Date: 2026-09-06

Context

The gateway historically required static long-lived credentials for every S3 destination: S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY for the global single-tenant client, and a persisted s3_compatible access/secret pair for per-workspace backends. BackendType::AwsRole existed in the schema but was rejected at configuration time. Static keys are a security liability and are awkward to rotate, and AWS-native deployments should be able to rely on short-lived, automatically-refreshed credentials instead.

At the same time, headless automation had no stable gateway credential: the only key material was either generated at startup (demo mode) or minted interactively, so operators had to copy a freshly generated secret on every cold start.

Decision

  1. Default credential provider chain for the global client. When S3_ENDPOINT is configured without static S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY, the global single-tenant client is built without an explicit credentials_provider, deferring to the AWS default chain (EC2 instance profile, ECS task role, EKS IRSA, SSO, OIDC web identity). Static keys still take precedence when both are present.

  2. aws_role per-workspace backends via STS. RuntimeBackendConfig::AwsRole carries a role ARN, region, and optional external_id. On resolution the gateway assumes the role with aws-sdk-sts (using the default credential chain), then builds the destination client from the returned temporary credentials (access key, secret, session token, expiry). The destination is always the canonical AWS regional endpoint (https://s3.<region>.amazonaws.com), so the operator endpoint allowlist does not apply; the trust boundary is the role ARN itself plus the identity allowed to assume it, reinforced by the optional external_id.

  3. Bootstrapped gateway key. MASKURA_BOOTSTRAP_KEY / MASKURA_BOOTSTRAP_SECRET (with permanent MASKURA_* aliases) seed a preconfigured key id/secret pair at startup when the pair is not already present. The secret is SHA-256 hashed and encrypted with the same envelope as generated keys. This is scoped to operator/headless bootstrap; interactive and production flows keep using the normal key-creation path.

Consequences

  • No long-lived AWS keys in the keyless path. Credentials resolve lazily via the ambient AWS identity, so rotation is handled by the platform (IRSA, SSO, instance profile) rather than by redistributing secrets.
  • STS assume-role happens per request. Temporary credentials are used for a single operation and are never serialized or persisted. This trades a per-request STS call for the absence of any credential cache; a short-TTL in-process cache is a follow-up if request volume justifies it.
  • aws_role config is validated at the boundary. Role ARNs must be arn:...:role/..., a region is required, and static credentials/endpoints are rejected for this backend type. The redacted dashboard response exposes external_id (it is a correlation value, not a secret) but never the temporary credentials.
  • Schema change. BackendConfigRequest and BackendConfigResponse gain an external_id field, requiring SDK regeneration via just build-sdks.
  • Bootstrapped keys are operator-managed. They are printed nowhere at startup and rely on the operator rotating them out of band; they must not be used in place of per-user keys where attribution matters.

ADR 0009: Durable Async Write Acknowledgements

  • Status: Accepted
  • Date: 2026-09-07

Context

Maskura currently acknowledges an object write only after filtering and the authoritative storage commit finish. This synchronous contract is simple and durable, but its latency includes the complete transform and provider path.

Two planning documents proposed incompatible meanings for asynchronous writes. One used full-async for an in-memory, fire-and-forget operation acknowledged before transformation. The other required every hosted asynchronous acknowledgement to identify a durable, recoverable write job. Reusing async for both contracts would make a successful response ambiguous and could cause clients to mistake accepted-but-losable work for a durable write.

Decision

Maskura has three write acknowledgement modes:

  • sync is the default. The existing S3 success response remains final and is returned only after the configured storage commit policy publishes the authoritative object generation.
  • half_async returns 202 Accepted only after transformed output, its authenticated encryption metadata, the immutable target plan, and the operation’s ordering fence are durably persisted.
  • async returns 202 Accepted only after source bytes or an equally durable source reference, the immutable transform and target plan, and the operation’s ordering fence are durably persisted.

Both asynchronous modes return a stable job ID and operation ID. An authenticated status resource is authoritative; resumable events may mirror durable state but cannot replace status lookup. Workers use leases and fencing, preserve receive order for one object key, publish at most one authoritative generation, and recover accepted work after process or machine failure.

Metering and billing occur exactly once after authoritative commit, not when a job is accepted. Failed, cancelled, or abandoned jobs are not billed. Retry is keyed by operation and target identity and may not create another visible version.

The canonical request policy name is x-maskura-write-mode; the permanent compatibility alias is x-maskura-write-mode. A request may only select a mode allowed by authenticated workspace policy. DELETE remains synchronous until its post-acknowledgement semantics receive a separate decision.

Hosted Maskura does not provide lossy fire-and-forget writes. If a self-hosted best-effort queue is ever added, it must be named best_effort, be disabled by default, and use a response contract that cannot be confused with durable job acceptance.

Consequences

  • A 202 from Maskura means the write can be recovered without relying on the accepting process or machine.
  • Async modes require encrypted durable staging, normalized job and target state, quota admission, retry/dead-letter policy, status retention, and cancellation semantics before either mode can be enabled.
  • Existing S3 clients remain on sync and retain their current final-response behavior.
  • The lower-latency half_async mode requires transformation to finish before acknowledgement; async shifts transformation to workers but consumes durable source-staging capacity.
  • In-memory fire-and-forget behavior cannot be marketed or configured as an asynchronous durability mode.

ADR 0010: Workspace-bound principals and hosted MCP transport boundary

  • Status: Accepted
  • Date: 2026-09-07

Context

API keys and MCP tokens were historically owned only by a dashboard user. On each data-plane request the gateway authenticated the credential and then asked the workspace repository to resolve that user’s current workspace. A durable credential could therefore move between workspaces when membership or default workspace selection changed. Existing rows contain no history from which the original workspace can be reconstructed safely.

The stdio MCP server also owned the tool request schemas, list result contract, and dispatch names. A hosted transport would otherwise duplicate those contracts or call the gateway through loopback HTTP with a synthesized credential header. The latter would turn an internal trust decision into spoofable network input and introduce a second auth path.

Decision

API keys and MCP tokens retain user_id as dashboard ownership metadata and gain an immutable workspace_id execution principal. MCP tokens also expose a stable credential UUID and credential-policy identity as one atomic authentication result. Creation resolves the user’s workspace once and persists it with the credential. Authentication uses that persisted workspace directly and never resolves a current/default workspace. Records with no valid workspace binding fail authentication.

The migration leaves genuinely unbound credentials null. Existing hosted UUID bindings are converted to canonical text and preserved after removing their incompatible foreign key. Database triggers reject changes to a credential’s workspace after insertion.

Transport-independent MCP request schemas, result types, validation, tool definitions, legacy aliases, dispatch, and S3 list parsing live in the small maskura-mcp-protocol crate. The stdio binary consumes those types and continues to call the network S3 surface with its configured credential. The stdio client sends credentials over HTTPS only, except when the configured host is a literal loopback address; non-loopback cleartext HTTP fails during startup.

Hosted adapters use maskura_gateway::server::invoke_mcp. They provide an already authenticated AuthenticatedMcpPrincipal, server operation UUID, typed tool request, hard-bounded request/response limits, timeout, and cancellation token. The gateway derives credential policy identity only from that principal, binds operation UUID reuse to the complete canonical operation, and carries trusted state in task-local storage unavailable to HTTP clients. The API accepts no authentication, metering, backend, or presigned URL headers.

Consequences

  • Credentials cannot silently follow a user into another workspace.
  • Legacy unbound credentials fail closed and require rotation.
  • Dashboard ownership and data-plane workspace scope remain separate facts.
  • Hosted MCP uses the same authorization, pipeline, storage, transaction, and usage paths as S3 without opening a loopback listener.
  • Text MCP bodies and responses have non-configurable hard ceilings. The private transport remains responsible for envelope and chunk preparse bounds.
  • Local stdio development remains simple over loopback HTTP, while a misconfigured remote gateway cannot receive MCP or API credentials in cleartext.
  • Cancellation reaches active Wasm work and waits for gateway settlement; provider SDK calls that do not expose cooperative cancellation may complete before the invocation returns its committed outcome.
  • On 2026-09-14, the operated service enabled the hosted transport after a release-bound production canary passed protected write/read, workspace and credential denial cases, health, cleanup, and sanitized attestation checks. This deployment status does not widen the trust boundary above or add OAuth or agent-payment authorization.

ADR 0011: Post-quantum hybrid envelope (X25519 + ML-KEM-768)

  • Status: Accepted
  • Date: 2026-09-07

Context

Maskura’s encryption filters use envelope encryption: a fresh AES-256-GCM data key (DEK) per field, wrapped with the client’s public key so only the key holder can decrypt. Today the DEK is wrapped with RSA-OAEP (SHA-256). A cryptographically relevant quantum computer breaks RSA via Shor’s algorithm, exposing long-lived ciphertext to harvest-now-decrypt-later.

The symmetric layers are already post-quantum-safe: AES-256-GCM resists Grover (~2^128 for a 256-bit key), as does the API-key SecretCipher and the KMS/Vault wrapping. The only quantum-vulnerable primitive in the data path is the RSA-OAEP key wrap.

Decision

Replace the RSA-OAEP DEK wrap with a hybrid X25519 + ML-KEM-768 key encapsulation:

  • ML-KEM-768 (NIST FIPS 203), a post-quantum KEM, protects against a CRQC.
  • X25519 ECDH, a classical KEM, protects against an undiscovered weakness in a young post-quantum scheme.

The construction is secure unless both are broken — the consensus position of the IETF hybrid key-exchange draft, NSA CNSA 2.0, and BSI. The two shared secrets are combined with HKDF-SHA256 into a single 32-byte DEK; the data cipher remains AES-256-GCM.

Scope is KEM-only: the data path contains no signatures, so ML-DSA and SLH-DSA are out of scope.

The envelope carries a new alg value: X25519+ML-KEM-768/AES-256-GCM.

Consequences

  • New client key format (hybrid public/private keys). The WIT public-key-pem config field is unchanged — it still carries a string.
  • Per-field wrap overhead grows from 256 B (RSA-2048) to 1120 B; the hybrid public key is 1216 B. Negligible for objects, meaningful for many-field records.
  • Expected fuel reduction: ML-KEM encapsulation has no modular exponentiation, versus RSA-OAEP’s ~25M wasm instructions per wrap.
  • Dual-alg read, single-alg write: existing RSA-OAEP envelopes remain decryptable; new writes are hybrid-only.
  • Implementation is planned, not yet shipped.

Alternatives considered

  • Pure ML-KEM-768 — post-quantum-safe but exposes the young-scheme risk alone; rejected in favor of hybrid.
  • ML-KEM-1024 — AES-256 parity but ~1.5 KB keys and larger ciphertext; deferred.
  • RSA-4096 — still broken by Shor’s algorithm; no post-quantum benefit.

ADR 0012: Local filesystem object storage

  • Status: Accepted
  • Date: 2026-09-08

Context

Maskura exposes an S3-compatible data plane but previously needed either an external S3-compatible service (commonly MinIO), a cloud bucket, or the development-only in-memory store. A self-hosted deployment could not persist objects with only the gateway container and a mounted volume.

The local backend must preserve the write pipeline’s all-or-nothing visibility:

Decision

Add FileStore, enabled for explicit single-tenant deployments by MASKURA_LOCAL_STORAGE_DIR.

  • Object content is written as a unique temp file, fsynced, and renamed to a version-named data file.
  • A per-key metadata JSON file atomically points to that data file. Renaming the metadata is the visibility commit, so readers observe the old complete object or the new complete object.
  • Keys are hashed for on-disk metadata lookup, avoiding path traversal and the foo versus foo/bar namespace collision. Metadata retains the original key, content type, MD5 S3 ETag, and size.
  • FileSinkTransaction streams transformed output to disk and commits through FileStore; single PUT does not use an operation journal because local rename gives crash-safe atomic object visibility.
  • FileStore supports core bucket and object operations. Durable multipart is the separate persistence and recovery architecture defined by ADR 0013, enabled explicitly with MASKURA_MULTIPART_MODE=staged.

Consequences

  • A gateway can be a standalone local S3 endpoint with a mounted volume and no MinIO, Postgres, or cloud credentials.
  • This is a single-node backend. It has no replication, versioning, lifecycle rules, or bucket IAM. ADR 0013 adds durable multipart, an exclusive process lock, encrypted staging, and crash recovery without changing that boundary.
  • Orphaned temp files are removed at FileStore startup. A crash after a data-file rename but before its metadata commit can leave unreferenced data, but cannot make a partial object visible.

Alternatives considered

  • Keep MinIO mandatory for durable local mode: rejected because it adds an unnecessary service and undermines standalone deployment.
  • Store object bytes in Postgres: rejected because object data does not belong in the relational control plane and scales poorly for large bodies.
  • Use filesystem extended attributes for metadata: rejected because they are not portable across Docker host filesystems. JSON metadata is portable and inspectable.

ADR 0013: Durable local multipart storage

  • Status: Accepted
  • Date: 2026-09-08

Context

ADR 0012 introduced a single-node FileStore for ordinary S3 object operations and deferred multipart until durable journaling existed. The current multipart pipeline uses two independent persistence boundaries: MultipartRepository owns client uploads, encrypted parts, quotas, completion fencing, replay, and cleanup, while OperationJournal owns destination transaction recovery.

Adding only a file operation journal would leave local multipart dependent on Postgres and an external S3 staging service. It would also leave a crash window between FileStore publication and durable completion replay state. A safe local implementation needs coordinated lifecycle persistence, encrypted artifact storage, exact destination commit proof, startup recovery, and cleanup.

The alternatives were a separate FileStore-native multipart protocol, an embedded database, and file-backed implementations of the existing generic contracts. A native protocol would duplicate security-sensitive multipart logic. An embedded database would add a second database architecture solely for local mode.

Decision

Implement local multipart through file-backed implementations of the existing backend-neutral contracts:

  • FileMultipartRepository for upload lifecycle, quota, outbox, fencing, replay, expiry, and audit state.
  • FileStagingArtifactStore for encrypted source-part artifacts.
  • FileOperationJournal for destination transaction state and recovery.
  • A backend-neutral fenced destination commit interface using durable PUBLISHING permits and backend-owned exact proof.

Store this internal state beneath <FileStore root>/.maskura/, separate from visible bucket data. Use version-1 checksummed framed event logs with bounded compacted per-upload and per-operation snapshots, owner-only permissions, unique temporary files, file sync, atomic rename, and directory sync. Corrupt durable state fails startup closed.

Generate and persist a local 256-bit wrapping key under the FileStore root on first use. The mounted data volume and wrapping key form one backup and recovery unit.

Support one active gateway process per FileStore root and enforce it in the local-storage runtime with an OS exclusive lock held for the runtime lifetime, whether multipart is enabled or not. Multi-process and distributed filesystem coordination are outside this backend’s contract.

Implementation status

Implemented for standalone local mode. Set MASKURA_STORAGE_MODE=local, MASKURA_LOCAL_STORAGE_DIR (default ./data), and MASKURA_MULTIPART_MODE=staged to enable it. The local repository, encrypted artifacts, operation journal, commit proofs, API key file, and generated wrapping key share the FileStore root. Hosted Postgres/S3 multipart remains unchanged.

The implementation is covered by unit recovery and fencing tests, standalone HTTP lifecycle and restart tests, deterministic crash-boundary tests, and unmodified Rust AWS SDK multipart tests over a real TCP listener.

The repository durably changes an owned completion lease to a fencing-bound PUBLISHING permit before destination mutation; publishing completions cannot be taken over by ordinary lease expiry. Serialize FileStore multipart publication, metadata-directory sync, commit receipt, and journal commit under the same FileStore mutation guard used by ordinary PUT and DELETE. Object metadata records deterministic operation and generation identity, and immutable commit receipts preserve exact proof across crashes, replacement, and deletion. Recovery requires exact operation identity; finding an arbitrary object at the same key is not proof of completion.

Run validation and reconciliation before serving traffic, followed by a bounded recurring cleanup and recovery worker. Multipart HTTP handlers and the transformation pipeline remain persistence-neutral.

Consequences

  • Standalone local mode can provide durable multipart uploads with one mounted volume and without Postgres, MinIO, cloud credentials, or an operator-managed wrapping secret.
  • The local wrapping key is security-critical backup material. Losing it makes incomplete encrypted multipart uploads unrecoverable.
  • A second process using the same root fails startup rather than risking split ownership or stale fencing.
  • Filesystem state and recovery logic are substantial, but shared contract tests prevent local behavior from drifting from Postgres/S3 multipart semantics.
  • Existing hosted and managed implementations retain their persistence backends and gain reusable fenced-commit orchestration rather than FileStore branches in request handlers. Artifact reads become provider-neutral rather than exposing AWS SDK stream types in the shared trait.
  • This decision does not add replication, distributed locking, versioning, lifecycle policies, ACLs, or other advanced MinIO features.
  • Implementation details and crash-state requirements are specified in docs/plans/2026-09-08-local-filesystem-multipart-phase-2.md.

ADR 0014: Supply-chain security gates and release provenance

  • Status: Accepted
  • Date: 2026-09-09

Context

Maskura processes security-sensitive object data, but its public CI previously relied on mutable GitHub Action tags and did not make dependency policy, RustSec advisories, CodeQL results, software bills of materials, checksums, or build provenance part of the enforced release path. A passing compiler and test suite do not prove which automation executed or let a consumer verify the origin and dependency inventory of a downloaded release.

Decision

Every external GitHub Action is referenced by a full commit SHA, with the human-readable release line retained as a comment for update tooling and reviewers. The aggregate check required on main includes cargo audit, cargo deny, pip-audit, npm audit, dependency-diff review, and CodeQL analysis of Rust, Python, JavaScript/TypeScript, and GitHub Actions workflows in addition to the existing format, lint, test, SDK, database, interoperability, and end-to-end jobs.

just pre-push is the canonical fast local gate. It runs formatting and clippy, the same Rust, Python, and npm dependency audits, and immutable-reference checks for workflow actions and container base images. The protected CI remains responsible for the long Wasm, SDK, database, interoperability, end-to-end, and full test suites. just push runs the local gate before delegating publication to jj git push.

The RustSec audit has one narrowly guarded exception for RUSTSEC-2023-0071. SQLx’s compile-time migration macro records the vulnerable rsa crate through its MySQL macro support even though Maskura enables only Postgres and the crate is absent from every resolved workspace target. The audit script first fails if rsa 0.9.10 becomes reachable in any workspace target, then applies the lockfile-only exception. This exception must be removed when SQLx no longer records that crate.

Release jobs generate an SPDX JSON SBOM and deterministic SHA-256 checksum manifest for downloadable files. GitHub artifact attestations bind those files to the release workflow. The canonical and compatibility multi-architecture container manifests are resolved to immutable digests and receive build provenance attestations that are also published to GHCR. Native Apple Silicon CLI and MCP binaries are built on an arm64 macOS runner, smoke-tested there, and included in the same checksum and artifact-provenance set as the Linux binaries. The release also includes the repository SBOM.

A release contract runs before an automated version tag is created and again when that tag enters the release workflow. It requires the tag, Cargo workspace and lockfile packages, generated OpenAPI and SDK metadata, and version-pinned public examples to agree. Native binaries must report that same version. This prevents an apparently valid tag from assembling mutually incompatible CLI, gateway, SDK, and documentation surfaces.

Versioned Linux AMD64 and ARM64 images are assembled first. Each architecture must pass the existing external-storage data-plane smoke plus the documented standalone local configuration with staged multipart enabled, including a container restart and transformed-object recovery. The versioned multi-arch image then runs the public redaction, runtime Wasm import, and Python hybrid encryption proofs. Only after those proofs, the complete native artifact matrix, SDK archives, SBOM, checksums, and provenance attestations succeed may the workflow move the latest aliases and publish the GitHub Release. A final consumer-side job downloads the published assets, verifies every checksum and a build attestation, and asserts that the public image exposes exactly the Linux AMD64 and ARM64 platforms.

Consequences

  • A moved upstream Action tag cannot silently change Maskura CI or release behavior; dependency updates require an explicit reviewed SHA change.
  • Dependabot proposes grouped weekly updates for Rust, both generated SDKs, GitHub Actions, and container images; security updates remain enabled.
  • Newly disclosed Rust, Python, or npm advisories and dependency-policy violations block the aggregate merge check rather than remaining a local-only command.
  • Code scanning covers source, generated clients, and workflow code and publishes findings through GitHub’s security surface.
  • Release consumers can verify file hashes, inspect the SBOM, and validate GitHub/Sigstore provenance instead of trusting a mutable download URL.
  • A failed assembly or proof can leave an unpublished versioned image for diagnosis, but it cannot move latest or publish a GitHub Release.
  • Every supported local execution architecture and every advertised native artifact is checked as a release invariant rather than inferred from the workflow matrix.
  • Security scans and release assembly take longer and can fail when upstream advisory data changes. That cost is accepted for a security boundary.
  • MPL-2.0 and CDLA-Permissive-2.0 are accepted for transitive dependencies; neither changes the Apache-2.0 license of Maskura’s own source.

ADR 0015: Release orchestration, authored notes, and Discord announcements

  • Status: Accepted
  • Date: 2026-09-10

Context

Releases are cut from main by .github/workflows/release.yml on v* tags. The version-bump workflow originally used a personal access token to push the tag so GitHub would start the release workflow as a second event. That added a maintainer-owned credential whose expiry or permission drift could stop all releases after the code had already merged. GitHub Releases previously used GitHub’s raw list of merged-PR titles. That text is accurate but reads as an internal changelog: it has no summary, grouping, or upgrade guidance, so it is a weak official note for users.

There was also no channel for telling users that a version shipped. An earlier proposal used the Reddit API to post to r/maskura, but Reddit requires a registered OAuth application and either a stored account password or a one-time refresh-token exchange, plus a moderator or approved-submitter relationship for the posting account. For a one-way release notification, that is a disproportionate credential and trust surface. A Discord Incoming Webhook needs a single URL, no bot, no OAuth, and no user account in the loop.

Decision

Release notes are authored in CI with no required human step:

  1. scripts/release-notes.sh fetches GitHub’s PR-based generated notes for the tag through the releases/generate-notes API. This reuses GitHub’s merged-PR discovery instead of reimplementing it.
  2. When DEEPSEEK_API_KEY is present, that text is rewritten by the DeepSeek chat API (deepseek-chat, overridable with DEEPSEEK_MODEL) into a summary plus grouped highlights, with breaking-change and upgrade sections only when the input supports them. The prompt forbids inventing features, versions, dates, or links and requires retaining input PR references.
  3. On a missing key, network error, timeout, non-success status, or empty response, the script falls back to GitHub’s generated notes. The LLM is an optional editorial pass, never a release dependency.

The resulting notes become both the GitHub Release body and the source of the announcement.

Announcements go to Discord through an Incoming Webhook (DISCORD_WEBHOOK_URL). scripts/post-release-discord.sh builds a rich embed with the title, notes, release link, timestamp, and footer, plus a “View on GitHub” button. It truncates notes past Discord’s 4096-character embed limit and links to the full release. Mentions are disabled in the webhook payload. The automatic step is gated on the release having been created in that run and is non-fatal, so a webhook problem never blocks a release and a workflow rerun does not double-post.

.github/workflows/announce-release.yml provides a manual replay for an already-published version, with a dry-run preview. It validates the tag input and reads the canonical release body from GitHub before posting.

tag-on-version-bump.yml creates the immutable tag with the scoped GITHUB_TOKEN, then invokes release.yml through workflow_call with that tag as an explicit input. GitHub does not fan out a new workflow from a tag pushed with GITHUB_TOKEN; the reusable-workflow call deliberately avoids depending on that behavior. Directly pushed v* tags remain supported by the release workflow’s existing push.tags trigger.

Container visibility is managed as persistent GHCR package configuration, not mutated during every release. The verify-release job intentionally does not log in to GHCR before inspecting the published manifest. That anonymous pull is the release-time proof that the package remains public; a visibility regression fails the release instead of being hidden behind a non-fatal API request.

The two optional integration secrets (DEEPSEEK_API_KEY and DISCORD_WEBHOOK_URL) are configured as GitHub Actions repository secrets and never appear in the repository. No personal access token is required for tagging or release orchestration. When DEEPSEEK_API_KEY is absent, releases ship with GitHub-generated notes.

Reddit publishing is deferred, not rejected; the same notes and secrets pattern can drive it later if a public community channel is wanted.

Consequences

  • Every release gets a summarized note with no required manual authoring, and users can be notified where they already are.
  • Notes are non-deterministic across runs and depend on an external LLM provider. The fallback makes this a quality risk rather than an availability risk; cost is bounded to one request per release with a 90-second timeout.
  • A Discord webhook is a bearer credential that can post to its channel. It is stored only as a GitHub Actions secret and rotated in the secret store.
  • Automatic tagging and release execution use short-lived, repository-scoped GitHub tokens; a maintainer PAT cannot expire underneath the release path.
  • Every release verifies the versioned multi-architecture image without GHCR credentials, covering both manifest contents and public pull access.
  • Discord announcement failures are non-fatal and may require a manual replay; the release itself remains available and verifiable.
  • No CHANGELOG.md is committed; GitHub Releases remain the canonical, per-version record.

ADR 0016: Maskura plugin contract and namespace

  • Status: Accepted
  • Date: 2026-09-10

Context

The original Component Model contract and several product surfaces retained the project’s pre-Maskura name. The host supported two versions of a filter-specific world, every plugin generated its own Rust bindings from a relative WIT path, and official components lived in one flat filters/ directory. That made the contract easy to drift and incorrectly suggested that plugins were limited to PII filtering and encryption.

There are no external Maskura plugin users yet. Carrying aliases now would make the old namespace a permanent compatibility and security-review burden without protecting a real deployment. A separate community-plugin repository would add governance and release machinery before contributor volume requires it.

Decision

The public Maskura repository owns one canonical WIT package, maskura:plugin@0.1.0, in crates/plugin-sdk/wit/world.wit.

The primary transformer world preserves the established streaming lifecycle: begin, transform, and finish. A plugin’s purpose is not encoded in a new world. Filters, cryptographic protections, validation, normalization, and future transformations share the transformer contract. Schema-aware binary adapters use the binary-reductor world in the same package.

maskura-plugin-sdk is a thin Rust authoring crate. It contains generated WIT types, the Guest trait, and the export_plugin! macro; it contains no gateway policy, storage, transport, or product logic. The host runtime binds directly to the WIT shipped by that crate. Raw WIT remains available to every other Component Model language toolchain.

Official components live under capability-oriented paths:

plugins/filters/<name>
plugins/crypto/<name>
plugins/transforms/<name>
plugins/shared/<internal-library>

Loadable components declare stable identity and lifecycle metadata in plugin.toml. official and experimental are maintenance states, not sandbox trust levels. New category directories appear only with a real plugin.

The 0.7 release is a clean namespace cut. Active crates, binaries, environment variables, headers, credentials, MCP tools and tokens, SDK imports, local state, release assets, container images, internal metadata, and cryptographic domain separators use Maskura names. The host does not load the old WIT packages, and the SDKs do not export old client aliases. The private hosted deployment is a coordinated consumer of the new contract and must update before rollout.

A separate community-plugin repository is deferred until outside contribution volume creates a concrete ownership, moderation, or release-cadence need.

Consequences

  • Plugin authors have one discoverable contract and one Rust dependency.
  • The gateway and its hosted deployment cannot silently disagree about the WIT shape; consumer checks compile and load the same canonical component.
  • Plugin categories can grow beyond PII without multiplying ABIs.
  • 0.7 intentionally invalidates pre-release components, credentials, local state paths, and deployment configuration that use the old namespace.
  • Operators must update public and private configuration atomically during the 0.7 rollout. There is no fallback alias.
  • Future ABI changes require a new WIT package version and an ADR; an existing published contract is never edited in place after external adoption.
  • A future community repository can depend on the same SDK without moving or forking interface ownership.

ADR 0017: Atomic managed-write admission

  • Status: Accepted
  • Date: 2026-09-16

Context

A managed write was admitted in steps, each in its own database transaction: insert_logical_operation committed a logical operation in the Intent state, then the caller read the workspace usage and reserve_logical_operation reserved provider exposure. If the reservation failed, the caller called prove_logical_abort to drive the already-committed Intent row to ProvenAborted.

That sequence exists only because the repository methods each open their own transaction. It spends database round trips on the hosted hot path (the ledger is the dominant cost of a managed PUT) and introduces an intermediate state — a committed Intent row with no reservation — that exists solely to be cleaned up. Reconciliation (pending_logical_operations) treats any operation that is not Committed/ProvenAborted as pending, so that intermediate state is also visible to recovery.

The physical provider mutation (the S3 write) happens only later, in the streaming sink, after admission succeeds. Nothing in admission mutates the provider.

Decision

Admit a managed write atomically. admit_logical_operation(intent, reservation_cap) inserts the logical operation and reserves its provider exposure in one transaction, clamping the reservation to the workspace’s available physical headroom (the same clamp the caller applied). The caller now makes a single call, and no longer calls prove_logical_abort on admission failure.

When admission fails (quota, or another managed mutation holding the workspace’s single mutation slot), the transaction rolls back and no logical operation row is committed.

The durable-preflight invariant is unchanged: the logical operation is still persisted before any provider mutation, because the provider write happens after admission succeeds. The Intent state remains reachable for operations that are inserted by other paths, and prove_logical_abort still handles the sink_begin_failed case, where the operation is admitted (open and reserved) but the sink fails to start and its reservation must be released.

Consequences

  • Removes one transaction, a repeated namespace lock, and a usage read per managed PUT (roughly four database round trips on the hosted hot path).
  • Eliminates the “committed Intent row with no reservation” state. A failed admission leaves nothing to reconcile instead of requiring an explicit prove_logical_abort; reconciliation no longer has to reason about it.
  • Behaviour change: a failed admission is no longer recorded as a ProvenAborted logical operation. This is intentional — admission never reached a provider mutation, so there is nothing to prove aborted — but any operator or metric that counted those rows will stop seeing them.
  • The in-memory repository keeps the two-step implementation (it has no round-trip cost) but exposes the same atomic contract.
  • Covered by postgres_managed_admission_is_atomic, which asserts that a second admission while a mutation is in progress fails with MutationInProgress and leaves no logical operation behind.

ADR 0018: Atomic managed-mutation finalization and recovery

  • Status: Accepted
  • Date: 2026-09-16

Context

Managed PUT completion previously consumed the physical write intent before a separate transaction published logical authority and released the workspace mutation slot. A crash between those transactions removed the recovery witness while leaving the logical operation and reservation non-terminal.

Recovery cannot safely rebuild authority from current placement or provider configuration. The admitted metadata, placement, copy statuses, selected provider version, and complete superseded-version history are immutable facts.

Decision

Persist a versioned publication recipe during logical admission and retain the physical intent until the parent is terminal. Logical PUT commit atomically verifies exact child evidence, records the full physical-version set, publishes authority from the recipe, transfers quota, releases the workspace slot, marks the operation committed, and deletes the intent last.

Logical abort is also one repository transaction. It requires either durable proof that no child could start or a terminal child journal with exact absence, then deletes the intent last while releasing the reservation and marking the parent proven aborted. Standalone physical reconciliation excludes every intent owned by a non-terminal logical parent.

Logical DELETE is one repository transaction rather than the staged PUT flow. It locks current authority, persists immutable zero-byte evidence and the exact tombstone publication recipe as a terminal COMMITTED operation, publishes the tombstone, enqueues cleanup only for ledgered physical targets, decrements visible logical bytes, and leaves no active mutation slot. Its pending settlement state is a durable outbox: the managed worker idempotently records the canonical zero-byte receipt and marks the operation settled after control-plane success. Authority repair and DELETE therefore serialize on the authority row: a repair either commits first and DELETE consumes its new CAS, or observes the tombstone after DELETE and cannot publish its stale CAS. A repair that ledgers its provider write after the tombstone enqueues cleanup, and cleanup completion rechecks for late ledger targets before becoming terminal. A failed transaction has no durable effect.

Stale PUT recovery uses renewable owner/token/expiry claims. Finalization and abort validate the matching unexpired claim; request-time operations remain valid only when no recovery claim exists. Deterministically missing or corrupt evidence enters RECOVERY_BLOCKED; persistence, concurrency, and lease races remain retryable. Legacy rows without recipes are never inferred.

All participating transactions use the lock order namespace, logical operation, workspace usage, current authority, then physical intent.

Consequences

  • A crash after provider commit retains enough immutable evidence for exact, idempotent finalization.
  • Request/recovery and recovery/recovery races have a single durable winner.
  • Ambiguous legacy or provider outcomes retain capacity and require operator review instead of publishing guessed authority or proving an unsafe abort.
  • DELETE removes visible bytes immediately but retains physical allocation until exact cleanup completion; missing and already-tombstoned keys remain idempotent.
  • An ambiguous DELETE commit retains its external reservation until the durable settlement worker proves and records the committed operation.
  • The workspace-wide mutation slot can still block unrelated keys while an operation is recovery-blocked; per-object isolation remains separate work.

ADR 0019: Zero-config local S3 appliance

  • Status: Accepted
  • Date: 2026-09-17

Context

MinIO retired its free Docker distribution, leaving self-hosters who used docker run minio/minio without an obvious drop-in replacement. Maskura already shipped the durable local FileStore (ADR 0012) and local staged multipart (ADR 0013), but reaching them required five explicit environment variables and the default read path returned 501 NotImplemented until MASKURA_STREAMING_READ_MODE=passthrough was set. A bare docker run of the published image failed at startup.

Decision

When no hosted or external storage configuration is present, the gateway boots as a zero-config local S3 appliance instead of failing startup.

  • Auto-local trigger. The appliance activates only when none of MASKURA_SERVICE_BUCKETS, S3_ENDPOINT, DATABASE_URL, MASKURA_STORAGE_MODE, MASKURA_LOCAL_STORAGE_DIR, or MASKURA_SINGLE_TENANT is set. Any explicit deployment signal opts out, so a cloud or hosted deployment can never silently degrade into a local store.
  • Defaults. Local durable storage under /data, staged multipart, and passthrough reads, listening on 9000 (the MinIO convention). Hosted and explicit deployments keep the historical 8080 default.
  • Byte preservation. The Wasm pipeline is disabled (an explicit pass-through), so uploaded bytes are stored and returned unchanged. PII redaction and encryption remain available but opt-in, reversing the previous “self-cleaning store” default.
  • Root credential. A SigV4 root principal is bootstrapped on first start: MASKURA_ROOT_USER/MASKURA_ROOT_PASSWORD override it (both must be set together), otherwise a strong pair is generated, persisted inside the data volume, and printed once. Auth is not bypassed: requests must still sign with the root credential.
  • Canonical bucket. A maskura bucket is created on first initialization so the first aws s3 cp s3://maskura/... works without a prior mb.
  • S3 semantics. Buckets are explicit: PutObject to a missing bucket returns NoSuchBucket; CreateBucket on an existing bucket returns BucketAlreadyExists; DeleteBucket on a missing bucket returns NoSuchBucket.
  • Fail-closed dispatch. Recognized-but-unsupported S3 subresources (?tagging, ?versioning, ?policy, ?acl, ?delete, …) and x-amz-copy-source (CopyObject/UploadPartCopy) return 501 NotImplemented before method/path dispatch, so they can never be misrouted into a destructive operation.
  • Readiness. A /ready endpoint and an OCI HEALTHCHECK are added so Compose can gate on readiness, not just liveness.

Consequences

  • docker run -p 127.0.0.1:9000:9000 -v maskura-data:/data ghcr.io/231self/maskura/maskura:latest yields a working single-node S3 endpoint with no further configuration.
  • The generated root secret is disclosed once in first-start container output. This is a deliberate, documented exception to the “never log secrets” rule, scoped to local-appliance initialization; later starts reuse the persisted credential silently.
  • The appliance is single-node and byte-preserving; it is not a claim of full AWS S3 or historical MinIO feature parity. Versioning, ACLs, policies, tagging, lifecycle, and copy remain unsupported and fail closed.

ADR 0020: Safe OpenTelemetry observability

  • Status: Accepted
  • Date: 2026-09-20

Context

The gateway emitted reviewed tracing events, but each process installed a fixed formatter with no request correlation, no runtime format controls, no OpenTelemetry exporters, and no operational metrics. Operators could not correlate a request end to end, and there was no sanctioned way to ship traces, logs, or HTTP RED metrics to a collector.

Telemetry is high-risk in an S3 data plane: concrete request paths, query strings, authorization headers, API and MCP credentials, presigned URLs, user agents, and raw error text are all capable of carrying customer or credential data. A generic exporter that forwards tracing fields would leak them.

Decision

Add server-owned correlation and a fixed-schema OTLP HTTP/protobuf export path, isolated behind an explicit trust boundary.

  • Server-owned correlation. The outermost middleware, applied only after the complete router is composed, removes any inbound x-maskura-request-id, ignores traceparent/tracestate, generates a UUIDv7 request ID, and returns it on every Axum-produced response. Protocol errors rejected by Hyper before Axum admission are outside the boundary.
  • One reviewed span. A single module-owned HTTP request server span is created with parent: None. It carries only six allowlisted attributes (maskura.request.id, http.request.method, http.route, http.response.status_code, maskura.http.status_class, maskura.http.outcome). Because tracing field names cannot contain dots, the dotted attributes are written directly on the OTel span from typed enums. http.route is the matched template or unmatched.
  • Typed completion log. One http.server.request.completed record is emitted directly through a private logger with exactly request_id, method, route, status_class, outcome, and duration_ms, plus trace/span IDs from the server-owned span. Generic tracing events never enter OTLP logs.
  • Bounded metrics. maskura.http.server.active_requests (up/down), maskura.http.server.completed_requests (counter), and maskura.http.server.request.duration (seconds histogram) use only normalized method, matched route, status class, and outcome. Active-request increment and decrement use the identical method+route attribute set.
  • Strict resource. The resource always carries service.name, service.version, and maskura.process.role, plus an allowlisted set of optional attributes; any other key, duplicate reserved key, or unbounded value fails startup.
  • Async sanitizing client. Maskura implements SanitizingHttpClient against opentelemetry_http::HttpClient using the workspace’s existing async Reqwest 0.12, so no second Reqwest version or blocking client is introduced. Transport errors are opaque and collector response bodies are stripped. The SDK’s async-runtime batch span/log processors and async-runtime periodic reader are enabled and constructed with runtime::Tokio; the default thread-based processors cannot drive an async HTTP client.
  • Failure isolation. Export is disabled unless an endpoint is configured. Collector failures occur in background workers and never affect request futures, health, readiness, billing, or reconciliation. Shutdown drains within a fixed deadline, drops the serve future at the deadline, then flushes within its own deadline; either timeout logs one fixed local warning.
  • Local controls. MASKURA_LOG_FORMAT=text|json and MASKURA_LOG_LEVEL=error|warn|info|debug apply only to Maskura targets; dependency targets stay capped at WARN and OTEL/HTTP-client targets are fully suppressed, including WARN and ERROR.

Rejected alternatives

  • OpenTelemetry 0.31: its SDK log-export pipeline is gated behind spec_unstable_logs_enabled, so logs could not be exported ungated.
  • Enabling the OTLP crates’ built-in Reqwest features: they pull Reqwest 0.13, creating two incompatible HttpClient type identities against the workspace’s 0.12.28.
  • A generic tracing to OTLP appender: it would forward application events and arbitrary fields, which cannot be bounded to a reviewed schema.
  • A public /metrics or Prometheus endpoint: it would add an unauthenticated surface and a second export format.

Consequences

The code contract is vendor-neutral OTLP HTTP/protobuf. Collector vendor, production endpoint, credentials, retention, and alert rules are deferred to a separately reviewed operations change; enabling export is not implied by merging code. Later phases (presigned delegation, ingress deadlines, panic containment, restrictive hosted CORS, and a shared internal error taxonomy) remain separate increments.

ADR 0021: Signed TOML pipeline configuration

  • Status: Accepted
  • Date: 2026-09-21

Context

Trust-boundary clarification (2026-09-23): ADR 0022 scopes this ADR’s operator-resistance language to artifact authenticity enforced by a trusted gateway. A valid configuration signature is not evidence of which binary or configuration a remote operator actually ran. The existing encoding, signing, and resolver decisions below remain in force.

Self-hosted Maskura selected pipeline plugins from one process-global catalog (PluginRegistry): every enabled plugin ran, in catalog order, for every PUT and every opt-in transformed GET. There was no declarative, per-workspace/bucket way to define processing chains, and the hosted control plane’s relational pipelines were not portable to OSS.

Operators need to author, review, and version a chain of processing as data — “codeless pipelines” — and the hosted dashboard needs to produce the same artifact for portability. Any tenant-defined policy must be verifiably authentic (ADR-0003) and must never be silently changeable by an operator.

Options considered:

  • Extend the catalog’s enable/order state. Already possible, but gives no per-scope selection, no per-step config or grants, and no authenticity.
  • Deserialize directly into the execution types (PipelineResolution). Requires hand-authoring content hashes and hosted-only identity fields.
  • A signed, hierarchical TOML file resolved through the existing PipelineResolver seam, with a CLI (and the dashboard) to author and sign it.

Decision

Adopt a signed TOML pipeline file as the OSS source of truth, resolved through PipelineResolver.

  • Hierarchical scopes: default write/read, buckets.<bucket>, workspaces.<id>, and workspaces.<id>.buckets.<bucket>. Precedence is workspace+bucket > workspace > bucket > default; a chain replaces rather than merges, and an unassigned direction fails closed.
  • Dedicated, ordered write and read chains per scope. There are no named pipelines and no assignment table.
  • (source, name, version) plugin identity, written as <source-uri>:<name>[:<version>]. OSS resolves references against locally loaded file:// components; an omitted version means the highest matching semantic version. Remote component fetch is not implemented. Filesystem components may carry adjacent *.plugin.toml version/world metadata; legacy components default to version 0.1.0 and the current transformer world. Name/version references trust the source to keep versions immutable; a digest name pins the component bytes cryptographically across restarts.
  • Optional versioned [identity] bindings (Slice 3): an export can bind each exact source/name/version reference used by its chains to a SHA-256 component digest and optionally a canonical step-configuration digest. Readers that participate in policy approval require this section explicitly; the gateway compares every binding to resolved component bytes before startup. Existing signed exports without the section remain valid as portable pipeline configurations but are not policy-approval identity evidence. The new section is signed by the same canonical body and changes its revision; older readers reject it as an unknown field.
  • Ed25519 over canonical CBOR of the parsed, validated model (the ADR-0003 construction); TOML whitespace and comments are not signed. The SHA-256 of that body is the immutable PipelineLocator.revision.
  • Fail closed at startup: every declared scope and step, including disabled and shadowed steps, is resolved before the gateway starts. Unknown keys, sources, components, versions, digests, unsupported worlds or grants, bad signatures, implicit empty chains, and ambiguous identities are hard errors.
  • Layered operator configuration: [wasm] pipelines_file, pipeline_trust_roots, and pipeline_allow_unsigned use the normal TOML plus environment override model. The unsigned override logs a prominent warning and labels the revision unsigned-dev.
  • Implemented in maskura-pipeline-config and crates/gateway/src/pipeline_config.rs; authored and signed with maskura pipelines.

The full design is recorded in docs/plans/2026-09-20-signed-toml-pipeline-config-design.md.

Consequences

  • Pipeline selection is immutable per request: the resolution (revision + fingerprint) freezes after authentication, and multipart uploads execute the exact revision they started with.
  • Editing a comment or whitespace does not invalidate the signature or change the revision; a semantic change does both.
  • The same file is the hosted dashboard’s portable export, so OSS and hosted share one schema; the private configuration can extend it with #[serde(flatten)].
  • Sensitive request context is deny-by-default and grantable only per step.
  • The catalog remains a catalog, not a policy; the file — not catalog order — decides which steps run.
  • Typed binary adapters such as Avro use their schema-aware binary transform pipeline rather than these byte-oriented WASM chains.
  • Deferred: prefix and content-type routing (requires extending the resolver input), remote component fetch, and hot reload.

ADR 0022: Policy approval and the execution trust boundary

  • Status: Accepted
  • Date: 2026-09-23

Context

Signed configuration establishes artifact authenticity relative to trusted signing keys. It does not establish which binary ran, which configuration that binary used for an operation, or whether plaintext was copied during processing. An operator controlling the gateway can replace both the processing code and its local verifier while continuing to serve a correctly signed artifact.

ADR 0002 selected a Nitro/TLS-in-enclave deployment, and ADR 0006 described its development-attestation counterpart. These historical decisions must not imply that the current gateway provides independently attested execution. The policy approval design also must not inherit that infrastructure as a prerequisite.

Options considered:

  • Hardware-attested execution with independently verified code/channel binding.
  • Mathematical computation proofs, including an asynchronous batched service.
  • Customer-verifiable approval evidence with enforcement by a trusted gateway.

Decision

Adopt the third, explicitly scoped trust model. Defer independent execution attestation to a much later product stage. Hardware attestation and batched computation proofs are not the current implementation direction. This decision supersedes ADR 0002 and ADR 0006; their text remains as historical context.

Retain the hosted design for standing policy envelopes, synchronous approval of effective-state changes, and purpose-separated passkey login MFA and policy approval. The gate requires a valid customer approval before activation and checks the exact approved effective state and envelope bounds in the trusted gateway. The dated refinements below distinguish historical rollout state, deployed Slice 5 enrollment/recovery, and accepted but unshipped staged-publication hardening. Accepting an architectural decision is not evidence of implementation or deployment.

For that exact-state check, a hosted request freezes its selected assignment ID in the pipeline locator and a versioned fingerprint; a revision alone cannot distinguish two assignments targeting the same pipeline. Static OSS locators and historical persisted snapshots keep their prior fingerprint interpretation. Policy-aware hosted enforcement must reject a missing assignment identity where an assignment was required; looking up a mutable assignment after the freeze cannot establish which assignment the request originally selected.

Existing canonical-CBOR/Ed25519 configuration signatures under ADR 0021 remain artifact-authenticity evidence. Neither those signatures nor WebAuthn approval receipts are per-operation execution proofs. This narrows the operator-resistance language in ADRs 0003 and 0021 without changing their existing signing formats. Future changes to WebAuthn schemas, signer lifecycle, and enforcement rollout need their own implementation evidence and protocol specification; trust-root lineage and per-lineage receipt-head retention are decided in ADR 0024.

Ordinary S3 onboarding remains endpoint + access key + secret. TLS/SigV4 does not attest server code. Native scale-to-zero remains a product constraint; there is no new protected-worker, client adapter, attestation-gated CA, external witness, or proof-generation service required by this decision.

Slice 3 refinement: approval evidence and request-path enforcement (2026-10-10)

The gate above is now specified in detail and partially built. This section records the Slice 3 trust-boundary decisions the original record lacked. It narrows nothing above; execution attestation stays deferred exactly as before.

Consumption, not possession (D8). A bound approval verdict that execution can discard is not authorization. The gate verifies the request and returns a binding; the data plane consumes it. At the storage-execution seam the engine re-verifies the request-time destination selection against the frozen binding before commit (consume_policy / binding_matches_selection in crates/gateway/src/policy_gate.rs); any drift answers policy.denied. Execution drives from the verified binding — the frozen destination, the frozen per-key managed placement targets, and the verified limits — and never re-resolves a mutable row after verification.

Frozen multipart state. Staged uploads persist the verdict admitted at create time (MultipartSnapshot.verified_policy) and continue under it across envelope rotation and expiry; later part/complete/abort operations re-check the frozen binding against their own fresh resolution (consume_frozen_policy). Session limits compose as min(create-time, current) at completion, so execution never exceeds either the original admission or current policy.

Canonical-CBOR evidence. ReceiptBody and Checkpoint are schema-versioned (v2) and digested as SHA-256 over Maskura’s versioned canonical encoding: a sorted-JSON-keys serde-then-CBOR convention (crates/pipeline-config/src/canonical.rs) — explicitly not RFC 8949 canonical CBOR. Unknown fields and unknown schema versions fail closed. WebAuthn approval evidence is ASN.1 DER ES256 over authenticatorData || SHA256(clientDataJSON), with the challenge bound to the complete receipt digest, so an assertion cannot be moved onto a different statement. These are customer approval evidence and consistency records, not per-operation execution proofs.

Evidence schema versioning. The durable frozen verdict is schema-versioned (VerifiedPolicy.schema_version, currently 1). Pre-versioning evidence reads as 0 with identical semantics; readers fail closed (policy.tampered) on newer versions — unknown future evidence is never silently accepted or reinterpreted.

Recovery and trust reset. Loss of all authorized keys requires a new independently pinned root and is recorded as a marked discontinuity, never as cryptographic continuity: a reset never signs an old-chain receipt, the old chain and its retained head stay separately verifiable, and a server-served trust bundle is never an independent pin. A customer must accept a new root out of band (ADR 0023, ADR 0024).

Enforcement surface. Enforcement is per workspace behind a policy-enforcement state, forward-only inactive → enforced. inactive is inert and behaviorally identical to the OSS engine. enforced fails closed: without a valid customer approval the request is rejected with policy.unprovisioned, never allowed. Other mismatches surface as policy.denied (state/bounds mismatch, unlisted operation or prefix), policy.expired (validity lapse), and policy.tampered (chain/digest or evidence-schema mismatch); all are typed S3 XML <Code> values on HTTP 403. Approved state is matched by digest against the exact frozen resolution — assignment-bound fingerprint, step digests and config hashes, grants, limits, envelope lockfile membership, and the v2 destination topology including the selected physical targets — so a revision or a mutable-row lookup cannot stand in for the frozen selection. For v1, managed storage is the only enforced destination mode; concrete/BYO destinations fail closed at provisioning until their destination wiring lands. Every S3 data-plane handler passes through the gate seam, including hosted MCP object operations dispatched in process (MCP tool listing/metadata is outside this claim).

Historical status and deployment state (2026-10-10). This is the rollout snapshot at that date, not the current enrollment status; see Slice 5 below. Three states must not be conflated. (1) Shipped in the public engine: the PolicyGate seam and typed policy errors (v0.7.16), the v2 effective-state/destination bindings and shared digest preimages (v0.7.19–v0.7.20), and verdict consumption — check/use at storage execution, create-time multipart freeze, min(create-time, current) limits, and verdict evidence schema versioning (v0.7.21–v0.7.22). With no gate configured these mechanisms are inert and OSS behavior is byte-identical. (2) The hosted enforcement gate is deploying now, per workspace, behind the policy-enforcement state; the gate that verifies approval evidence and returns a bound verdict runs in the hosted control plane, and while a workspace’s state is inactive it is inert. (3) No workspace is enforced. Flipping a workspace to enforced is a separate enrollment/cutover step that has not been performed and is not authorized by shipping the gate. Nothing in this record is execution attestation: a compromised gateway can still serve unapproved bytes in real time; that serving contradicts the signed record and is forensically visible afterwards — detection, not prevention.

Slice 4 note: customer verification shipped (2026-10-12). The customer-facing half of the boundary is now implemented and public: the EvidenceBundle v1 export format, a fail-closed verification facade over the shared protocol types, and the maskura policy CLI group with a customer-controlled trust store (see docs/policy-verification.md and ADR 0023’s status note). The claim stays exactly “customer-verifiable approval history against customer-retained checkpoints” — not execution attestation and not witness-verified. The external consistency witness (A3) remains a separately gated program: artifacts may carry witness acknowledgement records, but they parse only, absence is never blocking, and reports always carry witness: not-witness-verified. This slice is enforcement-neutral; the deployment states above are unchanged and no workspace is enforced.

Slice 5 enrollment and recovery refinement (2026-10-14)

Deployment status (updated 2026-10-16): the shared backup-authority protocol is merged in v0.7.25. Hosted enrollment/recovery and dashboard changes are deployed and accepted in hosted control 0.5.44. This confirms the Slice 5 flow below, not staged publication, fleet-wide enrollment, witness verification, or execution assurance.

The first landed customer state approval atomically advances the receipt head and transitions absent or inactive workspace enforcement to enforced. The authenticated approver and first activation time are retained on later approvals. Customer publish and rollback require completed enrollment; internal system-default seeding is unchanged. There is no operator flip or recovery bypass that clears enforcement.

The dashboard sequence is signer bootstrap → signed standing envelope → signed effective state. A separately registered passkey can become the single backup through a signed AddSigner transition with existing-authority approval and the exact new key’s counter-proof (ADR 0025). The backup can authorize replacement of a lost primary; login recovery codes are not policy-signing authority. Multi-owner recovery is deferred.

Loss of all policy keys starts a new root, not a continuation of the old chain. Explicit dashboard acceptance requires owner/full passkey step-up and the exact current root fingerprint. It retains browser-local old-root history, does not establish CLI pins, and does not mutate enforcement. Customer-controlled CLI import/confirmation remains separate. After reset the data plane stays fail-closed until the new root’s signed envelope and state land.

In that deployed release, configuration mutation still precedes its approval receipt: enforcement rejects the resulting mismatched state. Atomic staging is the accepted, unshipped hardening decision below. External witnesses remain separately gated and execution assurance remains unassessed; neither is supplied by enrollment or recovery.

Atomic filter-state publication refinement (2026-10-16)

Status (updated 2026-10-21): shipped, deployed, and gate-observed. The 2026-10-16 snapshot below said “implementation not shipped or deployed”; that is no longer accurate. The contract was implemented by the closed deliverable (maskura-private issue #284, owner-approved 2026-10-16 with the boundary quoted there): feat(policy): atomically publish staged signed filter-state and the follow-on staged-assignment-contract, staged-retirement-refusal, staged-multipart-cleanup, and live-acceptance-supplements series, released through hosted control 0.5.50. The deployment-verification observations this section requires run live in the hosted filter/multipart promotion gate — held-review begin/cancel/status, exact replay against the retained frozen result, and the pending/cancel/expiry journey proving serving state stays byte-identical while approved traffic continues — and were green on the 2026-10-20 promotion trains; atomic-failure and stale/replay additionally run in the local fault-injection suites (policy_staged_publication, policy_changes::tests::, enforced_path_matrix). The exclusions at the end of this section stand unchanged and are not scheduled: no witness, no execution assurance, no multi-owner recovery, no storage/probe rebinding, no new prefix routing or cryptographic algorithm. Fleet-wide enrollment and per-workspace cutover remain separate deliverables. The specification below is retained verbatim as the shipped contract.

Original snapshot (2026-10-16): decision accepted; implementation not shipped or deployed. This section specifies the target contract, not behavior available in hosted control 0.5.44. The existing mutation-first limitation remains until implementation and exact-release deployment acceptance establish otherwise.

Durable immutable review. For an enforced workspace, stage a separate durable candidate rather than changing serving configuration before approval. Freeze the operation, proposed effective state, exact receipt body, component/config digests, prospective publication/assignment identities, baseline preconditions and purpose-bound ceremony. Draft authoring remains separate. Review holds no database transaction or locks while the owner signs. Candidate payloads cannot be edited; changes require a fresh candidate and review. Pending work and retained terminal metadata must be bounded, and component holds retain bytes without authorizing retired or revoked components.

Atomic landing. Completion must freshly reauthorize the owner, session, full passkey step-up and signer under shared writer/revocation fences, recheck expiry after lock waits, and verify the proof against the stored body. Reject changed draft, assignment, head, root, authority, envelope or storage preconditions as stale; never silently rebase. Shared projection logic must match the frozen proposal both before mutation and against the actual written rows. Publish the configuration, receipt and authorization head, consume the ceremony and retain the committed result in one database transaction, or roll everything back. All competing effective-state and authority writers must share the fencing discipline; a lock used only by the completion endpoint is insufficient.

Coverage and retry semantics. Stage draft publication, forward rollback as a new generation, default/bucket assignment changes and bucket-assignment removal, including inherited outcomes. Retirement is refused with a typed error in this slice. Legacy/alternate mutation routes must refuse before mutation rather than bypass staging. Initial enrollment retains its existing signed activation flow. Pending review, cancellation and expiry leave the previous valid approved state serving; they do not extend envelope validity or undo revocation. A committed operation cannot be cancelled. Idempotent issuance and exact authenticated completion replay return the original candidate/result without another generation or receipt. After a lost response, reconcile committed status or retry the exact proof; never automatically compensate, restage or generate a new publication. Current authorization remains required for status/replay, and a new login session cannot complete a pending ceremony bound to an old session.

Protocol compatibility. No public signed-schema change is currently needed: existing receipt actions and EffectiveState bind the publication/rollback outcome. Private candidate/intent metadata is immutably associated with that exact signed body and cannot alter its effect. Preserve v2/v3 receipt verification, EvidenceBundle v1 and compatibility with the published v0.7.25 CLI. These are implementation requirements, not a claim that staged exports have passed acceptance. A newly discovered signed-semantic change requires explicit protocol review, not a silent extension or relaxed verification.

Admission and exclusions. Atomic writers do not turn request admission’s multiple reads into one database snapshot: a reader straddling the commit may briefly fail closed on an old-resolution/new-head mismatch. Denial-free admission is not promised, and accepting an arbitrary previous receipt is not a workaround. Frozen multipart create-time admission and min(create-time, current) limits remain unchanged. Storage/probe rebinding is excluded; a concurrent storage identity/epoch change invalidates the candidate, and an invalid live approval continues to deny. This decision adds no witness, execution assurance, multi-owner recovery, new prefix routing or cryptographic algorithm.

Compensating after a mutation was rejected because it exposes an unapproved state and races with other changes. Holding a transaction across human approval was rejected because locks/timeouts are not a durable review or recovery mechanism. Deployment must be verified separately, including atomic-failure, stale/replay, serving-during-review and verifier-compatibility checks. After enabling this contract, operational rollback must retain compatible enforcing behavior or hold mutations fail-closed, not return to a mutation-first binary.

Consequences

  • The operator, deployed gateway, host environment, and local enforcement state remain trusted during processing. Wasm sandboxing constrains plugins, not the administrator of the host runtime.
  • Envelope encryption keeps decryption keys client-side, but plaintext is available during gateway processing. This is not end-to-end confidentiality against the gateway operator or proof of absence of extra plaintext copies.
  • Offline approval verification requires independently trusted signer keys and authorized key transitions. A server-only key registry is not an independent trust anchor. Login recovery does not alone establish signing continuity.
  • Signature validity does not prove honest display of the policy to the human. Passkey prompts do not display the full approved configuration.
  • Hash chains establish consistency against retained checkpoints; they do not alone establish complete or latest history, or prevent split views. Verifiers must disclose these limits; this decision does not select a witness service.
  • No approval artifact establishes per-operation transformation correctness, exhaustive PII detection, absence of extra processing, or durable storage. Storage acknowledgments retain their separate documented semantics.
  • Documentation must distinguish deployed behavior, planned approval controls, and deferred execution assurance. Use “customer approval evidence and trusted-gateway enforcement,” not “operator-proof” or “end-to-end attested.”
  • Release provenance and executable integration checks remain useful and distinct: they do not identify what a remote service ran for an individual customer operation.
  • Enforcement is opt-in per workspace and forward-only; an inactive workspace sees identical behavior to the OSS engine, and only an enforced workspace’s requests can be refused for missing or mismatched approval evidence.
  • Fail-closed evidence evolution costs compatibility: unknown fields and newer schema versions are rejected rather than ignored, so older readers refuse evidence they cannot fully interpret. New evidence fields require a deliberate schema-version bump and migration story.
  • Multipart continuity is a bounded carve-out: in-flight uploads keep their create-time admission across rotation/expiry, but tightening still applies through min(create-time, current) limits and fresh per-request enforcement.

ADR 0023: Complete policy approval statements and authorization-state verification

  • Status: Accepted
  • Date: 2026-09-24

Context

The initial shared approval-library draft passed its unit tests but did not establish the intended approval contract: it expected raw ES256 signatures, left envelope-receipt metadata unsigned, relied on updated trust bundles for successor keys, and hashed export metadata instead of resolved runtime state.

The corrected library must support independent approval verification under ADR 0022’s trusted-gateway boundary. It must not imply that approval signatures prove execution.

Decision

  • Use WebAuthn-standard DER ES256 assertions. Require exact origin/RP/purpose binding and reject cross-origin approval ceremonies. Retain zero-counter support.
  • Version approval receipts and customer checkpoints as v2, rejecting the unshipped v1 draft. Every assertion binds the complete receipt body, including envelope digest/version, sequence, predecessor, epoch and pre-activation authority digest. Existing Ed25519 signed-file formats remain unchanged.
  • Include a versioned, typed EffectiveState projection covering ordered exact component identities, configs/grants, expanded operation routes, resource limits, adapter identity, and concrete immutable destination bindings. Export name/version references and download checksums alone are insufficient.
  • Effective-state schema v2 represents destinations as tagged concrete HTTPS targets or a managed weighted-rendezvous topology with sorted, uniquely identified physical backends, placement/authority version and non-secret credential-version evidence. Presigned approval binds a declared origin and bucket, never a signature query. The v1 single-destination draft cannot authorize an enforced workspace; there are no deployed v1 approvals to migrate. ReceiptBody stays v2 and signs the entire v2 state; digest vectors change with that state rather than silently reinterpreting old bytes.
  • Verify full history from independently pinned genesis authority, deriving each successor only from an authorized transition. The old authorized key signs the complete replacement credential/public key and retired credential identity; the replacement key countersigns the same statement and current effective state. An authorized backup may retire a lost credential. No updated server bundle is accepted as transition evidence.
  • Carry post-head authorization state, envelope high-water mark, effective-state digest, and root binding in independently retained checkpoints. Suffix verification resumes that state machine, including revocation and epoch checks.
  • Keep one serialized source of truth for credential keys. Verification must not use a cached key that can diverge from the bundle’s fingerprint.

The concrete contract and tests are described in the corrected Slice 1 protocol.

Slice 4 status (2026-10-12)

The verification contract is now customer-executable. verify_artifact in maskura-pipeline-config implements the fail-closed pipeline as one entry point — container/schema gate, canonical-CBOR digest re-derivation, the receipt-body invariants above, chain-link checks, approval-proof verification against the COSE key of a customer-pinned trust root (unpinned verification is refused), signer transitions with trust resets rendered as discontinuities (“discontinuity: new root not accepted” unless explicitly accepted), checkpoint extension/freshness against customer-retained checkpoints (extended-ok / gap / rollback-suspect / first-run), and validity at an evaluated instant (--at, default now with the ±60 s skew convention). The report is stable and versioned (verdict, findings[], completeness, witness: "not-witness-verified") and enforces completeness honesty: with no prior checkpoint it says UNVERIFIED (no prior checkpoint) and never claims a complete history. The maskura policy CLI wraps this for customers with a portable trust store whose pins are written only from customer-controlled input — a server-fetched trust bundle can never establish a pin. Golden vectors accompany the crate for third-party interoperability. Witness verification remains a gated program and is never claimed. See docs/policy-verification.md.

Consequences

  • These shared-library corrections are implemented locally without enabling hosted MFA, publication enforcement, recovery, or runtime gating. Those integrations still require their database, browser and request-path acceptance tests.
  • Revoked credentials cannot append by claiming an old epoch. Previously approved artifacts remain verifiable at their historical chain positions.
  • A lost-all-keys recovery is a new independently accepted root, not cryptographic continuity; login recovery codes cannot authorize it on their own.
  • Customers must retain trust material independently. A valid prefix/extension does not establish latest history or completeness, and a downloaded checkpoint is not automatically a trust anchor.
  • Runtime adapters must populate and compare the resolved projection rather than silently treating existing portable exports as exact-state approvals. Missing operation/format/storage coverage must fail closed at enforcement activation.
  • This decision adds no hardware attestation, computation proofs, external witness service, or additional S3-client onboarding requirement.

ADR 0024: Trust-root lineage and per-lineage receipt-head retention

  • Status: Accepted
  • Date: 2026-09-29

Refinement: ADR 0025 supersedes the one-active-signer restriction and the replacement-only signer receipt surface with signed backup-authority addition. The lineage/head-retention decisions and bootstrap preimage below remain unchanged.

Context

ADR 0022 introduced customer-signed policy approval evidence (envelopes, receipts) and reserved signer lifecycle, trust roots, and enforcement rollout for later implementation. ADR 0023 fixed the approval-statement and verification contract. Slice 3 now needs durable state for the remaining pieces: a signing registry, trust roots that survive a lost-all-keys reset, the per-request receipt-chain head, and the enforcement cutover flag.

Two requirements collide on the receipt-chain head table:

  1. The row-locked seq assignment point and the per-request signer_epoch check must operate on a single mutable active head per workspace.
  2. A trust reset (all authorized keys lost) starts a new independently pinned root, and every prior root and its head must remain immutable, separately verifiable evidence.

A workspace_id-primary-keyed head table satisfies (1) but cannot itself hold “immutable archived heads per lineage” (2) — one row per workspace cannot retain one frozen head per retired root. Keying the live head on (workspace_id, root_id) satisfies (2) but turns the hot seq-assignment row into a composite and makes “which head is active” implicit.

Decision

Active head + per-lineage archive. The schema keeps policy_receipt_heads as a single workspace_id primary-keyed active-head pointer whose root_id column is the active-root pointer (changed only inside the trust-reset transaction). A companion policy_receipt_heads_archive(workspace_id, root_id, receipt_id, seq, head_digest, signer_epoch, updated_at, archived_at) holds one frozen row per superseded lineage. A reset copies the prior head into the archive exactly as it stood; policy_receipt_heads_archive_guard then rejects every update or delete.

The rest of the Slice-3 schema follows the same immutable-evidence discipline (Slice-2 trigger style):

  • policy_trust_roots — permanent lineage records (genesis_bundle_digest, previous_root_id?, previous_head_digest?, recorded_by, created_at). previous_root_id/previous_head_digest are an unsigned discontinuity annotation naming what was retained, not continuity evidence; a new root never extends an old chain. recorded_by is the authenticated operator action, not proof of customer acceptance. No update, no delete.
  • policy_signers — identity and COSE key immutable, status forward-only (active → superseded | revoked), never deleted, one active signer per (workspace, root). signer_epoch increments per authorized transition within a root and restarts at 1 for a new root.
  • policy_enforcement_state — inactive → enforced once at cutover, forward-only and permanent.
  • Slice-2 policy_envelopes / policy_receipts gain root_id; envelope versions and receipt sequences are unique per (workspace_id, root_id) so a reset restarts each at 1 rather than colliding across roots.

The design rationale is recorded normatively in the Slice-3 protocol specification §8 (the record ADR 0022 defers schema detail to); this ADR captures the lasting storage/trust-boundary choice.

Bootstrap artifact_digest preimage. The first signer (bootstrap) and a trust reset establish a genesis TrustBundle and emit no chained receipt — SignerAuthority/ReplaceSigner is the only signer receipt and requires a replacement plus a retired credential that bootstrap lacks. So receipt_digest() does not apply, and the challenge’s artifact_digest needs its own preimage. We bind it to the exact authorized-signer statement from spec §5:

artifact_digest = sha256(canonical_cbor(SignerBootstrapBinding {
    purpose: "signer_bootstrap",   // fixed domain discriminant
    workspace_id,
    cose_public_key,               // base64url of the COSE_Key CBOR
    signer_epoch,
}))

Implemented as signer_bootstrap_digest() in maskura-pipeline-config (the single source of truth) and wrapped by policy_trust::bootstrap_artifact_digest() so ceremony issuance and assertion verification can never derive different bytes. The purpose discriminant gives the same domain separation ReceiptBody.purpose provides inside digest_of, so a bootstrap binding can never collide with a receipt or any other artifact digest. This is option (b) of the candidate preimages — a purpose-built statement digest over (workspace, COSE key, signer_epoch) — chosen for the most literal match to spec §5 and the easiest independent audit; (a) reusing digest_of(TrustCredential) was rejected because TrustCredential carries fields (label, status) not part of the authorized-signer statement.

Consequences

  • Enables: the hot seq-assignment path stays a single-row update on policy_receipt_heads; evidence retention across resets is first-class and immutable, so a customer can independently verify any prior root’s head without trusting a service-provided reset bundle.
  • Costs: one extra table and a copy step in the reset transaction; root_id must be threaded through envelope/receipt writers and the verification path; the active-root pointer lives on the head row, so “which lineage is active” is only answerable by reading policy_receipt_heads.
  • Trust posture: a lost-all-keys reset is an explicit discontinuity — a new genesis root that a customer must pin out of band — never a forged continuation. This is enforced by construction (no old-chain signature is emitted for a reset) and by the immutable lineage rows.

ADR 0025: Signed addition of a backup policy authority

  • Status: Accepted
  • Date: 2026-10-14

Context

Slice 5 requires one backup signing authority for single-owner recovery. The receipt protocol described by ADRs 0023 and 0024 supports replacing a signer, but replacement retires an existing credential. Adding a backup only in the hosted database would make the new authority unverifiable from the customer’s pinned root. Replacing the primary with the backup would defeat the requirement to retain both keys. Expanding the initial root instead would require repinning and would not provide an ordered, authorized addition to an existing lineage.

Decision

Use receipt schema v3 with an explicit SignerAuthority / AddSigner transition. The complete new credential is carried in the existing replacement wire field (now the new-authority field for both transition actions). replaced_credential_id must be absent for addition. The receipt binds the workspace, audience, existing envelope, approved effective state, predecessor, sequence, current authority digest, current epoch, and new credential.

An already authorized signer provides proof; the exact new credential provides counter_proof, both over the complete receipt digest. Credential IDs and public keys cannot reuse any existing authority, including revoked keys. Verification advances the epoch by exactly one, appends the new credential, and retains every existing credential’s status. The new key is authorized only from that next epoch. Addition cannot change the configuration or envelope it re-approves.

Readers retain v2 verification and canonical digest derivation unchanged. AddSigner is accepted only in v3; unknown versions and invalid action/field combinations fail closed. Writers use v3. Published v2 golden vectors remain unchanged. EvidenceBundle v1 and Checkpoint v2 remain unchanged containers; their receipt/authority contents are verified using the appropriate protocol rules. Older clients reject v3 instead of silently treating addition as replacement. Signer-transition evidence always requires linkage to pinned/retained history; a server-carried post-state alone never establishes new authority.

Hosted implementation must land the receipt, head advance, and signer row in one transaction with the existing head lock. It must enforce at most two active policy signers per root (primary plus one backup) under that lock, not merely at begin or in the UI. Existing-factor authorization and proof by the new key are required; login recovery codes alone never confer policy authority.

Lost-primary replacement remains a ReplaceSigner transition: the authorized backup approves, the successor proves possession, and the signed retired ID names the lost primary rather than the backup. Enforcement never becomes inactive during addition, replacement, or trust reset. Losing all authorized keys still requires an independently accepted new root with an explicit discontinuity.

Consequences

  • Customers can verify the backup’s authorization from retained history rather than trusting a server-edited signer list.
  • The public protocol must be released and hosted code repinned before hosted addition is available. Protocol support alone does not claim that the hosted ceremony or recovery UI is deployed.
  • Hosted signer uniqueness must allow two active credentials per root while preserving immutable identity and forward-only retirement. This supersedes ADR 0024’s one-active-signer restriction, not its lineage/head-retention model. Bootstrap still emits no receipt; its preimage remains unchanged.
  • No operator-only recovery backdoor, multi-owner recovery, witness assurance, or execution attestation is introduced.