Releases: enrichmeai/cistern
Release list
Cistern 0.2.0
The MCP front door. Any MCP client — Claude Desktop first — now connects to a pod and gets
exactly what its bound identity was granted: reads inside the grant, structured refusals
outside it, revocation that lands on the very next tool call, and receipts for all of it.
The four-beat flagship demo (works · refusal · live revocation · the receipt) runs end to
end against a real MCP client. Every MCP call crosses the same enforcement filter as every
HTTP caller — no privileged path, by architecture decision.
Added
MCP front door (cistern-mcp, T6.1 + T6.2, #37/#38)
- The MCP server: seven tools —
read-resource,list-container,write-resource(ETag
preconditions honoured),delete-resource,grant/revoke(the same.aclwrites the
CLI performs; Control enforced by the server),receipts(Control enforced) — built on
the official MCP Java SDK. Deliberately no search tool, and no MCP resources in v1:
nothing the door serves may outlive a revocation. - No privileged internal path (ARCHITECTURE decision 6): every tool call is a real HTTP
request to the running server — the standalone bridge over the network, the embedded
shape over its own loopback port — crossingAuthorizationFilter, decided by WAC afresh,
and leaving a receipt like any other client's request. - Static identity binding (T6.2, per the #89 ruling): one connection, one bearer
credential (cistern.mcp.credential/CISTERN_MCP_CREDENTIAL), resolved through the
ordinary resolver chain. An enabled door without a credential is refused at bind time. - Refusals are honest and structured: a 401/403 becomes an
isErrorresult whose text
beginsREFUSED, naming the resource and the required mode (computed by the server's own
RequiredAccesstable) — never an empty success, never a retry with other credentials. - Two launch shapes: the shaded bridge jar (
cistern-mcp-<version>-bridge.jar) a
desktop client launches against a running pod, andcistern.mcp.enabled=true, which makes
cistern-app serve MCP on its own stdio (with themcp-stdioprofile keeping stdout
protocol-clean). Claude Desktop configuration and the four-beat walkthrough:
docs/demo/claude-desktop.md.
Cistern 0.1.0
First tagged release: everything on main from the scaffold (2026-07-17) to the production
posture (T7.7, #122). It is a usable multi-pod server with real authentication: humans
authenticate through your own OIDC issuer and applications as service principals with hashed
credentials (or the owner uses a local bearer token on a private network), Web Access Control
is enforced on every request, grants are authored with one command and revoked on the very
next request, and every allow and deny leaves a receipt. It is not yet a Solid-OIDC/DPoP
server — interop with Solid identity providers is Phase 4 proper — and although this release
ships the production tooling ADR 0002 requires, the private-network posture remains the
default (see Known limitations).
Added
Core (cistern-core)
- Resource model and the storage SPI (
ResourceStore), with the shared
ResourceStoreContractTestkit every backend must pass and an in-memory reference
implementation (#50). - RDF I/O: Turtle and JSON-LD parse/serialize with base-URI resolution, over Jena (#51).
- Containment:
ldp:containsis derived from storage, never stored; client attempts to write
server-managed triples are rejected (#52). - N3 Patch engine —
solid:inserts/solid:deletes/solid:where(#56); resource kind
(container vs document) is a core-carried fact rather than inferred from the path (#70). - Ground-rule-7 retrofit: per-module message catalogues, an RDF media-type enum, a sealed
CisternExceptionhierarchy, one interface-metadata writer (#74).
Storage (cistern-storage-file)
- File-per-resource backend with metadata sidecars and crash-safe writes
(temp file +ATOMIC_MOVE); passes the contract kit (#53).
HTTP (cistern-webflux, Spring WebFlux, fully reactive)
GET/HEADwith content negotiation and LDP headers;HEAD=GETminus body (#62).PUTcreate/replace, intermediate containers created on demand, trailing-slash
container semantics (#66).POSTto a container: sanitisedSlug, non-overwriting name collisions,Link
interaction model (#68).DELETE:204;409on a non-empty container;405for the storage root (#63).- Conditional requests:
If-Match/If-None-Match,412decided before any write,304
on reads (#69). PATCHwith N3 Patch over HTTP (#70).OPTIONSand CORS:Allow/Accept-*from one table; open CORS with the request origin
echoed (Solid Protocol §8.1), narrowable withcistern.cors.*(#72).- Discovery: a storage description resource,
pim:Storageon the root,
solid:storageDescriptionlink on every resource (#73). - One RFC 9457 error mapper — every error is
application/problem+json; handlers never
speak status codes (#61). cistern.base-urlmints every identifier (configured, never taken fromHost), so
identifiers are stable behind a proxy and cannot be poisoned by a forged header.
Access control (cistern-wac, enforced in cistern-webflux)
- WAC engine evaluating
acl:Authorizationgraphs, deny by default;acl:Appendis a
subset ofacl:Write;acl:Controlimplies nothing else (#84). - ACL discovery: the resource's own ACL, else the nearest ancestor's
acl:default, stopping
at the storage root — and failing closed: an ACL that exists but does not parse denies,
an empty ACL still terminates the walk (#85). - Enforcement as a
WebFilterahead of every handler, so deny-by-default is structural:
401+WWW-Authenticate: Bearer realm="cistern"when no identity was proved,403when
an authenticated agent lacks the mode,WAC-AllowonGET/HEAD.DELETErequires
Writeon the parent container as well;POST/PATCHrequireAppend(#86). - Owner seeding: setting
cistern.owner.web-idturns enforcement on and seeds the root ACL
(acl:accessToandacl:default, never overwritten) granting that WebID full access.
cistern.owner.tokenadds the local bearer way in (Authorization: Bearer <token>) — one
of the three credential shapes below, and unset in production (#86, refined by #122).
Authentication (T4.0, #88, PR #110 — cistern-auth)
- One resolver seam, three credential shapes:
ChainedPrincipalResolver(first authenticated
resolver wins) chains the OIDC/JWT resolver, the service-principal registry and the owner's
local token, with anonymous as the fallthrough. The enforcement path is untouched — WAC
does not care how a WebID was proved. OidcJwtPrincipalResolver(Nimbus): bring your own issuer.issis compared verbatim to
cistern.auth.oidc.issuer,audmust contain a configured audience, keys come from
.well-known/openid-configuration(or a pinnedjwks-uri),exp/nbfhonour a
configurable clock skew, and the WebID is read from a claim (webid-claim) or minted from
a template (webid-template). Fixtures captured from a real IdP, never invented.ServicePrincipalRegistry: an application is its own principal —
cistern.auth.service-principals[n].web-id+.credential-hash(sha256:<hex>at rest),
no issuer required for machine clients.
Provisioning, grants and the cistern CLI (T5.6, #90, PR #113 · T5.7, #91, PR #111)
- Multi-pod provisioning, idempotent on every restart:
cistern.pods.seed[n]provisions pods
at boot (never overwrites an existing ACL; a malformed or duplicate root is refused at bind
time),PodProvisionerdoes the same for embedders, andcistern pod create --root </firms/acme/> --owner <webid>does it over HTTP under the caller's credential — the
server enforces Write and Control where the root goes. - Grant authoring that cannot lock the owner out:
GrantServiceandcistern grant <webid|public> --read|--write|--append|--control <path>write<path>.aclre-stating
whoever holds Control there today;cistern revoketakes back everything a grantee was
given and refuses to remove an authorization that grants Control. Exit codes: 0 ok,
1 failure, 2 refused (the server decides, not the tool), 3 conflict (the ACL changed
underneath; nothing written). - The CLI is a shaded executable jar (
cistern-cli-<version>.jar, picocli, no Spring) with a
bin/cisternwrapper.
Decision log and receipts (T5.9, #93, PR #114)
- Every authorization decision — allow and deny, every method — leaves one
DecisionRecord
naming the agent, the target, the mode required, the outcome and, when allowed, the ACL
that granted it; the record is written before the response is sent.AccessDecision
carriesdecidedByand the matched authorization IRIs. JSON Lines, one file per UTC day
under<storage root>/.cistern/decisions/, written through the storage SPI but outside
the pod's URI space (never listed, no HTTP path reaches it).GET <resource>?receipts[&from&to]
returns them asapplication/x-ndjsonto a holder of Control on the resource;
GET /?receipts&agent=<webid>is the owner's per-agent query.X-Request-Idis honoured
or minted and echoed on every response.cistern.audit.required(defaultfalse) makes
an unrecordable decision fail closed with 503 (CisternException.ServiceUnavailable); by
default the outcome stands and the failure is logged.k8s/demo.sh's sixth beat is the
receipt.
Packaging & operations
- Multi-stage
Dockerfile(non-root uid 10001,/datavolume, TCP healthcheck) and a
docker-compose.ymlbound to127.0.0.1:3737(#78, #80). - Kubernetes manifests for a local cluster (
k8s/):restrictedPod Security Standard,
RWO PVC, single replica withRecreate,ClusterIPonly, deny-all NetworkPolicy; the
owner credential from a Secret;k8s/demo.shshows a scoped grant and live revocation
(#79, #86, #87). - Integration kit (
integration-kit/, T7.10, #102, PR #108): Cistern + Keycloak — a real
OIDC issuer with a ready realm (two humans, two service principals, audiencecistern) —
plus a seeded pod and a sample app performing the allow/refuse sequence, in one
docker compose up; loopback only. - CI: build + full test suite on every PR; the Solid conformance harness as a report-only job
with honest numbers (#77); the Dockerfile built on every PR; k8s manifests and Terraform
validated without credentials. - Release pipeline (T7.14, #107, PR #109): a
v*tag runs the full suite, builds the image
natively per architecture, and publishes the multi-arch image to GHCR and a GitHub Release
whose body is this section — carryingcistern-app-<version>.jar, thecisternCLI
(cistern-cli-<version>.jarand thebin/cisternwrapper, smoke-run before either can
become an asset) andSHA256SUMSover all three.RELEASE.mdis the runbook: the gate,
theworkflow_dispatchrehearsal (publishes nothing), the tag commands, the post-release
stranger test.
Production posture (T7.7, #94, PR #122)
- Enforcement guard. A credential source —
cistern.auth.oidc.issueror
cistern.auth.service-principals[]— configured withoutcistern.owner.web-idrefuses
to start at bind time (ENFORCEMENT_REQUIRES_OWNER, naming the fix): enforcement is keyed
on the owner, so the credentials would never have been asked for and the pod would have
been open while its configuration read as locked. The owner's WebID and token do different
jobs: the WebID alone turns enforcement on and seeds the root ACL (Owner.isNamed()); the
token only adds the local bearer resolver (Owner.hasLocalCredential()) — the production
shape is WebID set, token unset, owner authenticating via OIDC or a hashed service
credential. Nothing configured still starts and warns (NO_OWNER_CONFIGURED: enforcement
is off); an owner named with no way to authenticate starts enforced and warns
ENFORCEMENT_WITHOUT_CREDENTIAL. - ADR 0002 supersedes ADR 0001: an instance may face the internet under eight conditions
(TLS in front;cistern.owner.web-idset;cistern.owner.tokenunset; per-tenant
isolation; backups drilled; `X-Requ...