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
maskuraCLI and localmaskura-mcpstdio 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
| Status | Meaning |
|---|---|
| Supported | Implemented on the local appliance. |
| Local appliance only | Implemented for the durable local FileStore; on a configured workspace or backend the request returns 403 AccessDenied. |
| Not implemented | Recognized and rejected before dispatch with 501 NotImplemented, without touching state. |
| Not routed | No route exists for the request; it does not reach a handler. |
Operations
| Operation | Request | Status | Evidence | Notes |
|---|---|---|---|---|
| ListBuckets | GET / | Supported | s3_frontdoor_test::list_buckets_at_root | Browsers get the dashboard on the same path; S3 clients get the XML. |
| CreateBucket | PUT /{bucket} | Local appliance only | s3_frontdoor_test::filestore_conformance_bucket_object_lifecycle_survives_restart, s3_frontdoor_test::create_bucket_is_rejected | Existing bucket → BucketAlreadyExists; a configured backend → 403 AccessDenied. |
| DeleteBucket | DELETE /{bucket} | Local appliance only | s3_frontdoor_test::filestore_conformance_bucket_object_lifecycle_survives_restart, file_store::delete_and_bucket_lifecycle_require_empty_bucket | Empty bucket → 204; non-empty → 409 BucketNotEmpty; missing → NoSuchBucket. |
| HeadBucket | HEAD /{bucket} | Local appliance only | s3_frontdoor_test::filestore_list_and_head_on_missing_bucket_return_no_such_bucket | Served by the GET /{bucket} route (body stripped). Missing bucket → 404 NoSuchBucket, existing → 200. |
| ListObjects (v1) | GET /{bucket} | Supported | s3_frontdoor_test::list_objects_returns_keys_and_prefixes, s3_frontdoor_test::filestore_list_and_head_on_missing_bucket_return_no_such_bucket | Prefix, delimiter, and pagination. Missing bucket → NoSuchBucket. |
| ListObjectsV2 | GET /{bucket}?list-type=2 | Supported | s3_frontdoor_test::memory_list_continuations_are_opaque_bound_and_tamper_resistant, s3_frontdoor_test::filestore_list_and_head_on_missing_bucket_return_no_such_bucket | Opaque, bound continuation tokens; continuation-token requires list-type=2. Missing bucket → NoSuchBucket. |
| PutObject | PUT /{bucket}/{key} | Supported | s3_frontdoor_test::put_get_roundtrip_filters_pii, s3_frontdoor_test::unmodified_rust_sdk_default_put_is_accepted | With no plugins configured, bytes are stored unchanged. |
| GetObject | GET /{bucket}/{key} | Supported | s3_frontdoor_test::streaming_memory_get_preserves_range_and_head_metadata, s3_frontdoor_test::conditional_get_and_head_preserve_object_identity_without_a_body | Supports Range and conditional request headers. |
| HeadObject | HEAD /{bucket}/{key} | Supported | s3_frontdoor_test::head_and_delete_remain_available | Metadata only; no body. |
| DeleteObject | DELETE /{bucket}/{key} | Supported | s3_frontdoor_test::head_and_delete_remain_available | Missing key → 404 NoSuchKey. |
| CreateMultipartUpload | POST /{bucket}/{key}?uploads | Supported | s3_multipart_conformance::sdk_multipart_lifecycle_completes_over_real_tcp | Staged and durable; requires the local staged multipart store (enabled on the appliance). |
| UploadPart | PUT /{bucket}/{key}?partNumber&uploadId | Supported | s3_multipart_conformance::sdk_multipart_lifecycle_completes_over_real_tcp, local_filesystem_multipart::local_filesystem_multipart_lifecycle_over_http | Parts persist across restart. |
| CompleteMultipartUpload | POST /{bucket}/{key}?uploadId | Supported | s3_multipart_conformance::http_conformance_completion_errors_pagination_helpers_and_replay | Completion replaces any existing single-PUT object atomically. |
| AbortMultipartUpload | DELETE /{bucket}/{key}?uploadId | Supported | s3_multipart_conformance::sdk_multipart_abort_list_uploads_and_no_such_upload | Unknown upload → NoSuchUpload. |
| ListParts | GET /{bucket}/{key}?uploadId | Supported | local_filesystem_multipart::local_filesystem_multipart_lifecycle_over_http | Full part listing with pagination fields. |
| ListMultipartUploads | GET /{bucket}?uploads | Supported | s3_multipart_conformance::sdk_multipart_abort_list_uploads_and_no_such_upload | In-progress uploads for the bucket. |
| CopyObject | PUT /{bucket}/{key} + x-amz-copy-source | Not implemented | s3_protocol::s3_not_implemented_returns_501, crates/gateway/src/server.rs (x-amz-copy-source pre-dispatch gate) | Rejected before the body is polled. |
| UploadPartCopy | PUT ?partNumber&uploadId + x-amz-copy-source | Not implemented | crates/gateway/src/server.rs (s3_upload_part copy-source check) | Rejected before any part is stored. |
| DeleteObjects (batch) | POST /{bucket}?delete | Not routed | crates/gateway/src/server.rs (no POST /{bucket} route; delete is a gated subresource) | Returns 405 Method Not Allowed. |
| SelectObjectContent | POST /{bucket}/{key}?select | Not implemented | s3_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
| Claim | Command | Assertion |
|---|---|---|
| Maskura is an S3-compatible local endpoint | just proof redaction | An 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 runtime | just proof plugin | The 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 envelope | just proof python | The 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:
- Encryption design and exact construction
- Security model and trust boundaries
- Full MinIO end-to-end and external-client coverage
- Filter cost and reproducible benchmark method
- Architecture decisions
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 proofchecks 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.
| Function | Called | Purpose |
|---|---|---|
begin(context) | once per object | Receive format, operation, policy, bounded config, and explicitly granted sensitive inputs |
transform(payload) | once per record | Return emit(bytes), drop, or reject(reason) |
finish() | once at the end | Flush 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:
- The gateway generates a random 256-bit data encryption key (DEK) and encrypts the field with AES-256-GCM.
- The DEK is then key-encapsulated to your public key, so only the holder of the matching private key can recover it.
- 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
| Role | Primitive | Why |
|---|---|---|
| Data cipher | AES-256-GCM | Authenticated encryption; 256-bit keys resist Grover (~2^128) |
| Classical KEM | X25519 (ECDH) | Protects against an undiscovered weakness in a young PQ scheme |
| Post-quantum KEM | ML-KEM-768 (FIPS 203) | Protects against a cryptographically relevant quantum computer |
| Combiner | HKDF-SHA256 | Folds 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>"}
algidentifies the construction. ExistingRSA-OAEP/AES-256-GCMenvelopes remain decryptable (dual-alg read); new writes are hybrid-only.ivis the 12-byte AES-GCM nonce.enc_dekis the 1120-byte hybrid encapsulation (base64).ct/tagare 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-----
| Component | Size |
|---|---|
| X25519 public key / shared secret | 32 B |
| ML-KEM-768 encapsulation key (public) | 1184 B |
| ML-KEM-768 ciphertext | 1088 B |
| ML-KEM-768 shared secret | 32 B |
| ML-KEM-768 seed (private key serialization) | 64 B |
| Hybrid public key | 1216 B |
| Hybrid private key | 96 B |
enc_dek | 1120 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_dekgrows 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-OAEPenvelopes remain decryptable (dual-alg read).
What does not change
- The plugin model, the
transform/finishinterface, 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:
| size | fuel | ms/object | MiB/s |
|---|---|---|---|
| 1 KB | 18,396 | 0.052 | 18 |
| 64 KB | 18,592 | 0.084 | 730 |
| 1 MB | 18,592 | 0.567 | 1683 |
2. PII scan cost (redaction filters, 1 MB, 0 PII)
Detection/redaction scans every byte; cost is linear in size:
| plugin | fuel/byte | ms/object | MiB/s |
|---|---|---|---|
| pii-default (email+card+ssn) | 49.5 | 2.48 | 385 |
| email-detect | 41.5 | 2.31 | 413 |
| ssn-detect | 45.5 | 2.17 | 440 |
| card-detect | 45.5 | 2.10 | 454 |
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:
| record | fields | fuel | ms/object | MiB/s | expansion |
|---|---|---|---|---|---|
| 1 KB | 1 | 35.1M | 0.92 | 1.0 | 5.86x |
| 1 MB | 100 | 3.42G | 86.7 | 11.0 | 1.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:
| record | fields | fuel | ms/object | MiB/s |
|---|---|---|---|---|
| 1 MB | 0 | 1.02M | 0.59 | 1609 |
| 1 MB | 1 | 29.6M | 1.50 | 637 |
| 1 MB | 100 | 46.8M | 1.97 | 483 |
Cost summary (multiplier vs. noop, 1 MB)
| plugin | fuel/byte | vs noop (fuel) | MiB/s |
|---|---|---|---|
| noop | ~0.02 | 1x | 1683 |
| stable-encrypt (0 fields) | ~1.0 | ~55x | 1609 |
| envelope-encrypt (0 fields) | 28.6 | ~1540x | 563 |
| email-detect | 41.5 | ~2230x | 413 |
| ssn-detect | 45.5 | ~2450x | 440 |
| card-detect | 45.5 | ~2450x | 454 |
| pii-default | 49.5 | ~2660x | 385 |
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
releaseprofile (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-encryptexhausts 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-encryptnumbers assume a valid hybrid X25519 + ML-KEM-768 public key in the session context and host-generated entropy;stable-encryptuses 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, andstringvalues. - 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, anddecimalvalues. - 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:
plan(source-schema-ir)returns a reduced schema, owned claims, and an opaque reduction plan.reduce(plan, source-value-ir)runs once per source value.- The host applies schema-aware binary transforms to reduced values.
plan-restore(source-schema-ir, transformed-reduced-schema-ir, plan)returns the final output schema and an opaque restoration plan.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
planandplan-restoreoutput 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_objectmaskura_get_objectmaskura_list_objectsmaskura_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
AuthenticatedMcpPrincipalcontaining 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 inscripts/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). Otherwisedocker compose -f local/docker-compose.yml up -d miniostarts one, torn down on exit. - Bucket
maskura-localis created if missing. - Gateway (AUTH_DISABLED) on
$MASKURA_E2E_GW_PORT(default9010), single-tenant streaming against that MinIO, an isolatedkeys.json, the builtpii-defaultcomponent, andMASKURA_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
| Script | Concern | What it proves |
|---|---|---|
10-http-surface.sh | HTTP 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.sh | Avro gate | A PUT with Content-Type: application/avro is rejected (501) while MASKURA_ENABLE_AVRO is unset, and nothing is stored |
20-redaction-roundtrip.sh | Core redaction | maskura 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.sh | Auth enforcement | Boots 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.sh | Keys + S3 data plane | Dashboard 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.sh | Plugin management | Import 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
ListObjectsv1 + v2 and byte-faithful authenticated PUT/HEAD/GET/DELETE against a real S3 backend (MinIO). GET/DELETE /dashboard/api/keyshappy paths and the dashboard key format.- Dashboard HTML,
/openapi.json+/docsserving, and legacy tombstone 410s. - Plugin import / list / enable / reorder / remove across the real HTTP router.
- Data-plane + dashboard denial on a non-
AUTH_DISABLEDboot (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_BUCKETSand noS3_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-gateddb_keys_testCI job. - Key expiry/revocation rejection on the data plane — needs an
auth-enabled boot that can create keys (with
AUTH_DISABLEDunset, 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
- Create
scripts/e2e/features/NN-short-name.shthat sources../lib.sh, callsbegin_feature, asserts withexpect_status/assert_contains/assert_absent, and ends withend_feature "<name>". - Use unique object keys and, when mutating the gateway state, place the
feature last (as
40-plugin-admin-http.shdoes). - If the feature needs its own gateway, boot it on its own port and keys file
and stop it on
EXIT(see25-strict-auth-denial.sh). - Keep it deterministic in CI: only
bash,curl,python3, Docker, and the artifacts the orchestrator already builds. Remembercurl --data-binaryimplies POST unless-X PUTis given.
Troubleshooting
- If MinIO is already running on
:9000the orchestrator reuses it; give it a moment or confirmcurl http://127.0.0.1:9000/minio/health/livesucceeds. - 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 theFAIL: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:
maskura-gateway --config <path>MASKURA_CONFIG=<path>./maskura.toml, if it exists- 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 key | Environment override | Default and behavior |
|---|---|---|
server.listen_addr | LISTEN_ADDR | Unset. 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.disabled | AUTH_DISABLED | false. Bypasses authentication and implies explicit single-tenant mode; use only for local development. |
Storage and Supabase
| TOML key | Environment override | Default and behavior |
|---|---|---|
storage.mode | MASKURA_STORAGE_MODE | Unset; the only accepted configured value is local. Mutually exclusive with storage.s3_endpoint. |
storage.s3_endpoint | S3_ENDPOINT | Unset. HTTP(S) URL for process-global S3-compatible storage; allowed only in explicit single-tenant mode. Credentials remain environment-only. |
storage.s3_region | S3_REGION, then AWS_REGION, then AWS_DEFAULT_REGION | Unset; effective S3 client region is us-east-1. The first non-empty override wins. |
storage.single_tenant | MASKURA_SINGLE_TENANT | false. Enables global or local storage for a single-tenant deployment. |
storage.local_dir | MASKURA_LOCAL_STORAGE_DIR | Unset. 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.url | SUPABASE_URL | Unset; 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 key | Environment override | Default and behavior |
|---|---|---|
features.multipart_mode | MASKURA_MULTIPART_MODE | reject; 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_mode | MASKURA_STREAMING_READ_MODE | off; accepted values are off, passthrough, and transformed. transformed also requires transformed_read_spool = true. |
features.transformed_read_spool | MASKURA_TRANSFORMED_READ_SPOOL | false. 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_avro | MASKURA_ENABLE_AVRO | false. Enables Avro processing. |
features.streaming_s3_provider | MASKURA_STREAMING_S3_PROVIDER | Unset; accepted values are aws, minio, r2, and b2. Required so Maskura can verify support for direct streaming to global S3. |
features.dev_memory_streaming | MASKURA_DEV_MEMORY_STREAMING | false. Enables the development in-memory streaming path outside explicit single-tenant mode. |
features.managed_streaming_mode | MASKURA_MANAGED_STREAMING_MODE | off; accepted values are off, observe, and enforce. observe/enforce require transactional mode and durable hosted dependencies. |
features.managed_streaming_transactional | MASKURA_MANAGED_STREAMING_TRANSACTIONAL | false. Declares that the managed backend supports the required transactions. |
Wasm pipeline
| TOML key | Environment override | Default and behavior |
|---|---|---|
wasm.filter_component | MASKURA_DEFAULT_PLUGIN | Unset; 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_dir | MASKURA_PLUGINS_DIR | Unset. Existing components in this directory are loaded at startup. |
wasm.fuel | MASKURA_WASM_FUEL | 1,000,000,000 instructions per pipeline session; must be greater than zero. |
wasm.prefix_safe_component_hashes | MASKURA_PREFIX_SAFE_COMPONENT_HASHES | Empty 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_file | MASKURA_PIPELINES_FILE | Unset. Path to a signed TOML pipeline file; when set, replaces catalog-order selection. |
wasm.pipeline_trust_roots | MASKURA_PIPELINE_TRUST_ROOTS | Unset. Operator-only semicolon- or comma-separated signer_id=hex-public-key entries. |
wasm.pipeline_allow_unsigned | MASKURA_PIPELINE_ALLOW_UNSIGNED | false. 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 key | Environment override | Default and behavior |
|---|---|---|
limits.source_max_frame_bytes | MASKURA_SOURCE_MAX_FRAME_BYTES | 8 MiB; must be positive and fit the platform usize. |
limits.max_object_bytes | MASKURA_MAX_OBJECT_BYTES | 5 GiB. Positive values may lower but cannot raise the 5 GiB hard limit for source data. |
limits.max_pipeline_output_bytes | MASKURA_MAX_PIPELINE_OUTPUT_BYTES | 5 GiB. Positive values may lower but cannot raise the 5 GiB hard limit for pipeline output. |
limits.legacy_max_object_bytes | MASKURA_LEGACY_MAX_OBJECT_BYTES | 16 MiB. Positive values may lower but cannot raise the 16 MiB hard limit for the legacy path. |
limits.dev_memory_max_object_bytes | MASKURA_DEV_MEMORY_MAX_OBJECT_BYTES | 16 MiB. Positive values may lower it or raise it to at most 64 MiB. |
spool.dir | MASKURA_SPOOL_DIR | The OS temporary directory plus maskura-spool. |
spool.max_object_bytes | MASKURA_SPOOL_MAX_OBJECT_BYTES | Effective object limit; cannot exceed limits.max_object_bytes. |
spool.quota_bytes | MASKURA_SPOOL_QUOTA_BYTES | Twice 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 key | Environment override | Default and behavior |
|---|---|---|
keys.keys_file | MASKURA_KEYS_FILE | Unset. 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_key | MASKURA_BOOTSTRAP_KEY | Unset. Non-secret bootstrap access-key ID; must be paired with env-only MASKURA_BOOTSTRAP_SECRET. |
managed.placement_version | MASKURA_MANAGED_PLACEMENT_VERSION | 1; must be greater than zero. Bump it when changing a durable managed placement policy. |
Multipart staging
| TOML key | Environment override | Default and behavior |
|---|---|---|
multipart_staging.endpoint | MASKURA_MULTIPART_STAGING_ENDPOINT | Unset. HTTP(S) URL for the hosted staging object store. |
multipart_staging.bucket | MASKURA_MULTIPART_STAGING_BUCKET | Unset; must not be empty. |
multipart_staging.region | MASKURA_MULTIPART_STAGING_REGION | Unset; must not be empty. |
multipart_staging.dir | MASKURA_MULTIPART_STAGING_DIR | Unset; must not be empty. Temporary hosted staging directory. |
multipart_staging.tenant_quota_bytes | MASKURA_MULTIPART_STAGING_TENANT_QUOTA_BYTES | In 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_bytes | MASKURA_MULTIPART_STAGING_GLOBAL_QUOTA_BYTES | In 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 key | Environment override | Default and behavior |
|---|---|---|
sigv4.region | MASKURA_SIGV4_REGION | us-east-1; expected SigV4 credential-scope region. |
sigv4.trusted_tls | MASKURA_SIGV4_TRUSTED_TLS | false. Set only when a trusted proxy terminates TLS before the gateway. |
allowlists.workspace_endpoint | MASKURA_WORKSPACE_ENDPOINT_ALLOWLIST | Empty. Hosts or *.suffix entries trusted for multi-tenant persisted workspace endpoints. |
allowlists.workspace_endpoint_private | MASKURA_WORKSPACE_ENDPOINT_PRIVATE_ALLOWLIST | Empty. Exact private hosts; valid only in explicit single-tenant mode. |
allowlists.presigned_http | MASKURA_PRESIGNED_HTTP_ALLOWLIST | Empty. Hosts or *.suffix entries allowed for presigned HTTP(S) sources. |
allowlists.presigned_http_private | MASKURA_PRESIGNED_HTTP_PRIVATE_ALLOWLIST | Empty. Exact hosts allowed to resolve to private addresses. |
allowlists.presigned_http_allow_http | MASKURA_PRESIGNED_HTTP_ALLOW_HTTP | false. Permits HTTP only for presigned source GET; destinations remain HTTPS-only. |
allowlists.presigned_http_min_validity_secs | MASKURA_PRESIGNED_HTTP_MIN_VALIDITY_SECS | 30 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 variable | Default and behavior |
|---|---|
MASKURA_LOG_FORMAT | text or json; default text. |
MASKURA_LOG_LEVEL | error, 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 variable | Default and behavior |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | Base 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_ENDPOINT | Per-signal endpoints; enable only that signal. |
OTEL_TRACES_EXPORTER, OTEL_LOGS_EXPORTER, OTEL_METRICS_EXPORTER | otlp 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_NAME | Non-empty bounded printable-ASCII service name; defaults to maskura-gateway. |
OTEL_RESOURCE_ATTRIBUTES | Comma-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_ARG | always_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_TIMEOUT | Bounded 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_TIMEOUT | The same bounds for the log batch processor. |
OTEL_METRIC_EXPORT_INTERVAL, OTEL_METRIC_EXPORT_TIMEOUT | Metric 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 variable | Purpose and default/behavior |
|---|---|
MASKURA_CONFIG | Selects the config file when --config is absent. It is not a TOML field. |
DATABASE_URL | Postgres 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_KEY | Static 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_SECRET | Dashboard JWT verification secret. Unset by default. |
SUPABASE_ANON_KEY | Supabase public/anonymous client key. Defaults to the local Supabase development value. |
MASKURA_SECRET_KEK | Base64-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_BUCKETS | Semicolon-separated service-storage backends. Each backend is pipe-separated as `provider |
MASKURA_MULTIPART_STAGING_ACCESS_KEY_ID, MASKURA_MULTIPART_STAGING_SECRET_ACCESS_KEY | Required credential pair for hosted multipart staging. |
MASKURA_BOOTSTRAP_SECRET | Secret paired with keys.bootstrap_key / MASKURA_BOOTSTRAP_KEY; both must be present or absent. |
MASKURA_ROOT_USER, MASKURA_ROOT_PASSWORD | Optional 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:
- 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=truebypasses auth and is a local-development-only mode. - 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.
- 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).
- 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:
- Shipped in the public engine — the request-path
PolicyGateseam (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 answerspolicy.denied), staged multipart uploads persist their create-time verdict and continue under it across envelope rotation/expiry with complete-time limits composed asmin(create-time, current), and frozen verdicts are schema-versioned with unknown future versions failing closed. - The hosted enforcement gate — deploying now, per workspace, behind a
policy-enforcement state (
inactive= inert;enforced= fail-closedpolicy.unprovisionedwithout 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. - Future enrollment/cutover — no workspace is enforced today; flipping a
workspace to
enforcedis 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:
- Maskura SDK header path — the client sends the plaintext secret in
x-maskura-access-key/x-maskura-secret-keyheaders orAuthorization: Bearer <access_key>:<secret>. The gateway comparessha256(secret)against the storedsecret_hash. Requires TLS. - 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=…withx-amz-dateandx-amz-content-sha256(mandatory for header auth). - Query auth (presigned) —
X-Amz-*query parameters; presigned requests without an explicitx-amz-content-sha256header default toUNSIGNED-PAYLOAD, which is only accepted over trusted TLS. - Signed-header integrity —
hostis always signed, and header auth also requires signedx-amz-dateandx-amz-content-sha256. In every SigV4 mode, every presentx-amz-*header exceptx-amz-user-agentandx-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, andcontent-md5, plus their exact legacy aliases), must appear inSignedHeaders, 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 pinnedaws-sigv4presigner excludes them.x-amz-checksum-modeis 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, defaultus-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 declaredx-amz-content-sha256value 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 matchx-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_DISABLEDlocal 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 aKeyWrappingimplementation (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
| Provider | Durability | When 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) | Durable | Production |
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 + WITmaskura: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
Storeand 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 asSlowDownrather 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
WorkspaceStorageRepositoryto a canonicalWorkspaceId. 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-Typeagainst a known format (JSON/JSONL/CSV/TSV/text), rejectsRange,partNumber, non-identityContent-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_HASHESat 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=encryptedis 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
HEADon transformed reads —HEADis 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_MODEgates the path:off(default) rejects transformed reads entirely;passthroughenables only raw streaming;transformedenables 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_compatiblewith an endpoint, region, and static access credentials, oraws_role(IAM role ARN- region + optional
external_id) that is assumed via STS per request. Runtime S3-compatible endpoints are governed byWorkspaceEndpointPolicy(see §10), and dashboard reads return only a redacted configuration.
- region + optional
- 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: managedrequest 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=trueorMASKURA_SINGLE_TENANT=true) may continue to the globalS3_ENDPOINTand then in-memory storage. - Multi-tenant fail-closed boundary — startup rejects
S3_ENDPOINTand requires non-emptyMASKURA_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, orMASKURA_MULTIPART_STAGING_DIRin hosted mode) framed asMSKMP1magic + 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.
CompleteMultipartUploadis 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 returnsServiceUnavailableinstead 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, withCOMMIT_UNKNOWNandPROVEN_ABORTEDfor 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_UNKNOWNreconciliation. When a completion outcome is ambiguous (the request failed aftercompletewas issued), the operation transitions toCOMMIT_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
enforcemode accepts only exact lowercaseb2andawsprovider 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
ObjectAuthorityrow 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_moderefuses to turn managed mode off after authority rows exist, and refusesobserve/enforcewithoutDATABASE_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*.suffixwildcards). - Scheme — HTTPS is required.
MASKURA_PRESIGNED_HTTP_ALLOW_HTTP=truepermits HTTP only for an explicit presigned sourceGET; presignedPUTandDELETEdestinations 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-ExpiresorExpires) with at leastMASKURA_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
*.suffixentry inMASKURA_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
COMMITTEDandPROVEN_ABORTEDrows 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_ENDPOINTstorage still requires explicitMASKURA_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_UNKNOWNoperations 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_BYTESand decoded object bytes byMASKURA_MAX_OBJECT_BYTES; per-record decoder limits apply; andMASKURA_LEGACY_MAX_OBJECT_BYTESis no longer load-bearing. CompleteMultipartUploadXML 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, andmaskura.http.outcome.http.routeis the router’s matched template (for example/v1/objects/{*key}orunmatched), never a concrete path or query. - The completion log carries exactly
request_id,method,route,status_class,outcome, andduration_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=trueif 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_KEKis 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_DIRand 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 requiresDATABASE_URLplus the completeMASKURA_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 requiresMASKURA_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_ENDPOINTclient accepts staticS3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEYand, 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-workspaceaws_rolebackends assume an IAM role via STS: temporary credentials are used once per request, never serialized, and the role ARN plus an optionalexternal_idform 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_FILEorkeys.jsonin the image; do not setAUTH_DISABLEDin production; put the spool and staging directories on private, capacity-reserved volumes sized forMASKURA_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) andjust 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:
- 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.
- 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.
- 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
Storeper 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
maskurasigns and activates. - Manifests have short expiry; monotonic version warnings on stale manifests discourage rollback.
- Implementation uses
ciboriumcrate 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
Storelifetime 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
-
Default credential provider chain for the global client. When
S3_ENDPOINTis configured without staticS3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY, the global single-tenant client is built without an explicitcredentials_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. -
aws_roleper-workspace backends via STS.RuntimeBackendConfig::AwsRolecarries a role ARN, region, and optionalexternal_id. On resolution the gateway assumes the role withaws-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 optionalexternal_id. -
Bootstrapped gateway key.
MASKURA_BOOTSTRAP_KEY/MASKURA_BOOTSTRAP_SECRET(with permanentMASKURA_*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_roleconfig is validated at the boundary. Role ARNs must bearn:...:role/..., a region is required, and static credentials/endpoints are rejected for this backend type. The redacted dashboard response exposesexternal_id(it is a correlation value, not a secret) but never the temporary credentials.- Schema change.
BackendConfigRequestandBackendConfigResponsegain anexternal_idfield, requiring SDK regeneration viajust 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:
syncis 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_asyncreturns202 Acceptedonly after transformed output, its authenticated encryption metadata, the immutable target plan, and the operation’s ordering fence are durably persisted.asyncreturns202 Acceptedonly 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
202from 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
syncand retain their current final-response behavior. - The lower-latency
half_asyncmode requires transformation to finish before acknowledgement;asyncshifts 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-pemconfig 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
fooversusfoo/barnamespace collision. Metadata retains the original key, content type, MD5 S3 ETag, and size. FileSinkTransactionstreams transformed output to disk and commits throughFileStore; 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:
FileMultipartRepositoryfor upload lifecycle, quota, outbox, fencing, replay, expiry, and audit state.FileStagingArtifactStorefor encrypted source-part artifacts.FileOperationJournalfor destination transaction state and recovery.- A backend-neutral fenced destination commit interface using durable
PUBLISHINGpermits 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
latestor 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:
scripts/release-notes.shfetches GitHub’s PR-based generated notes for the tag through thereleases/generate-notesAPI. This reuses GitHub’s merged-PR discovery instead of reimplementing it.- When
DEEPSEEK_API_KEYis present, that text is rewritten by the DeepSeek chat API (deepseek-chat, overridable withDEEPSEEK_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. - 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.mdis 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
Intentrow with no reservation” state. A failed admission leaves nothing to reconcile instead of requiring an explicitprove_logical_abort; reconciliation no longer has to reason about it. - Behaviour change: a failed admission is no longer recorded as a
ProvenAbortedlogical 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 withMutationInProgressand 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, orMASKURA_SINGLE_TENANTis 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 on9000(the MinIO convention). Hosted and explicit deployments keep the historical8080default. - 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_PASSWORDoverride 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
maskurabucket is created on first initialization so the firstaws s3 cp s3://maskura/...works without a priormb. - S3 semantics. Buckets are explicit:
PutObjectto a missing bucket returnsNoSuchBucket;CreateBucketon an existing bucket returnsBucketAlreadyExists;DeleteBucketon a missing bucket returnsNoSuchBucket. - Fail-closed dispatch. Recognized-but-unsupported S3 subresources
(
?tagging,?versioning,?policy,?acl,?delete, …) andx-amz-copy-source(CopyObject/UploadPartCopy) return501 NotImplementedbefore method/path dispatch, so they can never be misrouted into a destructive operation. - Readiness. A
/readyendpoint and an OCIHEALTHCHECKare 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:latestyields 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, ignorestraceparent/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 requestserver span is created withparent: 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). Becausetracingfield names cannot contain dots, the dotted attributes are written directly on the OTel span from typed enums.http.routeis the matched template orunmatched. - Typed completion log. One
http.server.request.completedrecord is emitted directly through a private logger with exactlyrequest_id,method,route,status_class,outcome, andduration_ms, plus trace/span IDs from the server-owned span. Generictracingevents never enter OTLP logs. - Bounded metrics.
maskura.http.server.active_requests(up/down),maskura.http.server.completed_requests(counter), andmaskura.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, andmaskura.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
SanitizingHttpClientagainstopentelemetry_http::HttpClientusing 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 withruntime::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|jsonandMASKURA_LOG_LEVEL=error|warn|info|debugapply 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
HttpClienttype identities against the workspace’s 0.12.28. - A generic
tracingto OTLP appender: it would forward application events and arbitrary fields, which cannot be bounded to a reviewed schema. - A public
/metricsor 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
PipelineResolverseam, 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>, andworkspaces.<id>.buckets.<bucket>. Precedence isworkspace+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 loadedfile://components; an omitted version means the highest matching semantic version. Remote component fetch is not implemented. Filesystem components may carry adjacent*.plugin.tomlversion/world metadata; legacy components default to version0.1.0and 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 exactsource/name/versionreference 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, andpipeline_allow_unsigneduse the normal TOML plus environment override model. The unsigned override logs a prominent warning and labels the revisionunsigned-dev. - Implemented in
maskura-pipeline-configandcrates/gateway/src/pipeline_config.rs; authored and signed withmaskura 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
inactiveworkspace sees identical behavior to the OSS engine, and only anenforcedworkspace’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
EffectiveStateprojection 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:
- The row-locked
seqassignment point and the per-requestsigner_epochcheck must operate on a single mutable active head per workspace. - 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_digestare an unsigned discontinuity annotation naming what was retained, not continuity evidence; a new root never extends an old chain.recorded_byis 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_epochincrements per authorized transition within a root and restarts at 1 for a new root.policy_enforcement_state—inactive → enforcedonce at cutover, forward-only and permanent.- Slice-2
policy_envelopes/policy_receiptsgainroot_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_idmust 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 readingpolicy_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.