External anchoring

External anchoring periodically writes each record chain's signed checkpoint to S3-compatible object storage, under Object Lock in COMPLIANCE mode whenever Object Lock is on (VAULT_ANCHOR_OBJECT_LOCK, on by default). The mode itself is not a setting: every anchor this Server writes with Object Lock enabled is COMPLIANCE, never GOVERNANCE.

Every write is also create-only (If-None-Match): a second write to a position already anchored is refused by the store rather than layered over the first, and is reported as a fork. Taken together, a locked version cannot be altered or deleted, and a store that honours the precondition refuses the second write outright, for the retention period, whether the write comes from an attacker or from an administrator of the AGLedger Server. POST /v1/admin/vault/anchors/reconcile names a store that does not honour conditional writes in its posture report, because on that store the guarantee is not in force.

This is the layer that makes tamper-evidence hold against an operator who holds the database and the signing key but not the bucket, not only against an outside attacker.

Why anchoring matters

Every chain entry is signed and hash-linked, so any modification to stored history is detectable on verification, provided the verifier trusts the signing key. An administrator who holds both the database and the active signing key (or, under KMS custody, the right to call Sign with it) can, in principle, rewrite a chain and re-sign it; the rewritten chain would verify cleanly. Anchoring closes this gap:

Anchoring detects tampering after the fact; it does not prevent it in real time. Without it, all tamper-evidence layers live in the same PostgreSQL database a privileged attacker would rewrite, which is why a production Server logs a boot warning while anchoring is disabled.

What gets anchored

Checkpoints are per record chain, swept incrementally on the anchor interval: each sweep checkpoints the record chains that advanced since their last checkpoint, in record order, for as long as its time budget allows, and a sweep that runs out of time resumes where it stopped on the next one. The platform-ops chain and each org's schema chain are checkpointed and anchored on every sweep as well. Each anchor object is a small JSON document (about a kilobyte) carrying the checkpoint's canonical COSE_Sign1 envelope (base64), the record ID, chain position, payload hash, signing key ID, the instance ID, a document version, and the anchor timestamp. Record payloads never leave your database; the anchor is a cryptographic commitment, not a copy.

Objects land at a deterministic key inside the configured bucket:

vault-anchors/<instance>/<recordId>/<chainPosition>.json

where <instance> is the instance id this database recorded on its first boot, from AGLEDGER_INSTANCE_ID. Every install gets its own: the Compose installer generates a UUID and writes it to your .env, and the Helm chart generates one into the release's ConfigMap on first install and keeps it across upgrades (the instanceId value pins it instead). So the segment is that UUID rather than a friendly name. A Server where nothing set an id anchors under default, and a Helm release whose ConfigMap carries no id (one installed by a chart that set none) keeps default, the prefix it has always anchored under.

Once an id is set it lives in the database, so a restore onto a rebuilt host keeps writing under the same prefix, and a boot whose AGLEDGER_INSTANCE_ID disagrees with it is refused, naming both values. Read the id from vault.anchoring.instanceId in GET /v1/admin/ops-summary, which reports the id the database carries, or from .env or the chart's ConfigMap (the values.yaml comment on instanceId has the command), before going looking for objects by prefix. A Server reads only its own prefix.

One chain position is one key for the life of the install, which is what lets the create-only precondition catch a re-anchored position. Verification, reconciliation and the anchor listing read only this layout. Releases before 1.8.0 wrote under a dated key, vault-anchors/<instance>/<YYYY-MM-DD>/<recordId>/<chainPosition>.json, which none of them read. A 2.0 Server starts from a new database, so its own checkpoints never have a dated anchor; a dated object in the bucket belongs to a 1.x install that shared the instance id.

Because checkpoints are per-chain, the object count scales with how many chains advance per interval, not with a fixed per-tick rate. A low-volume install produces a handful of objects per sweep; a high-volume install produces one per chain that advanced.

Checkpoints are created even when anchoring is disabled. They are the in-database layer of the same control. Enabling anchoring adds the external, immutable copy.

Enable it (Docker Compose)

Anchoring is disabled by default. In your .env:

VAULT_ANCHOR_ENABLED=true
VAULT_ANCHOR_S3_BUCKET=my-org-agledger-anchors
VAULT_ANCHOR_S3_REGION=us-east-1
# VAULT_ANCHOR_S3_ENDPOINT=http://minio:9000   # non-AWS S3-compatible stores (MinIO, etc.)
VAULT_ANCHOR_INTERVAL_MINUTES=360              # tamper-exposure window; see below
VAULT_ANCHOR_OBJECT_LOCK=true                  # COMPLIANCE-mode immutable retention (default)
VAULT_ANCHOR_RETENTION_DAYS=2555               # ~7 years (default)

Credentials come from the standard AWS credential chain (instance role, AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, or a profile). Two processes touch the bucket:

Grant only s3:PutObject to the worker and the daily sample-verify job it runs returns s3_error for every checkpoint it samples. The API's own verify endpoint is unaffected, because it authenticates to the bucket with its own s3:GetObject and s3:ListBucket grant rather than the worker's.

Defaults if unset: bucket vault-anchors, region from AWS_REGION (falling back to us-east-1), Object Lock on, retention 2555 days, interval 360 minutes.

http://minio:9000 in the endpoint line is a container named minio on the stack's Compose network, which the installer does not create. Run the store on that network so the api and worker can resolve it (docker network ls shows the network; it is compose_default under the default project name), or point the endpoint at an address they can reach. .env is read when a container is created, so after editing it recreate the stack from the compose/ directory:

cd compose && docker compose up -d

Enable it (Kubernetes / Helm)

The chart passes these through extraEnv (there is no dedicated vault.anchor values block):

extraEnv:
  - name: VAULT_ANCHOR_ENABLED
    value: "true"
  - name: VAULT_ANCHOR_S3_BUCKET
    value: my-org-agledger-anchors
  - name: VAULT_ANCHOR_S3_REGION
    value: us-east-1

The chart generates AGLEDGER_INSTANCE_ID itself, so leave it out of extraEnv; the render refuses an entry of that name and points at instanceId. Pin instanceId when the chart cannot read its own ConfigMap back:

Two releases on one id and one bucket would anchor under one prefix.

With the chart's NetworkPolicy on (the default), the api and worker may reach only DNS, the database, the OTLP collector and port 443 on public addresses. An anchor store anywhere else needs an egress rule in networkPolicy.extraEgress: an in-cluster MinIO on 9000, or a store on a private address such as an S3 interface endpoint.

networkPolicy:
  extraEgress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: minio
          podSelector:
            matchLabels:
              app.kubernetes.io/name: minio
      ports:
        - port: 9000
          protocol: TCP

On EKS, grant the bucket credentials through IRSA: put the role ARN in serviceAccount.annotations (eks.amazonaws.com/role-arn: <role arn>). The chart has no per-workload service account, though: api, worker, migrate and the preflight Job share one ServiceAccount, so the API pod ends up holding the same bucket-write permission the worker needs. There is no chart-native way to give the worker anchor-write access without also giving it to the API.

Prepare the bucket

Create the bucket with Object Lock enabled (an existing bucket takes it only once versioning is on, through aws s3api put-object-lock-configuration):

aws s3api create-bucket \
  --bucket my-org-agledger-anchors \
  --region us-east-1 \
  --object-lock-enabled-for-bucket

AGLedger applies per-object COMPLIANCE-mode retention of VAULT_ANCHOR_RETENTION_DAYS as each anchor is written. In COMPLIANCE mode no one, including the account root, can delete the object or shorten its retention until it expires. That permanence is the point; choose the retention deliberately.

For the strongest posture, put the bucket in a separately-administered account (or a different provider), so no single set of credentials controls both the database and the anchors. A bucket in the same account as the Server is a single trust domain.

Air-gapped installs: point VAULT_ANCHOR_S3_ENDPOINT at MinIO (path-style addressing is handled automatically) and enable MinIO's object locking on the bucket. On Kubernetes, open egress to it with networkPolicy.extraEgress (above).

Choose the interval

VAULT_ANCHOR_INTERVAL_MINUTES (default 360, from 5 to 2880, snapped to cron granularity) is a risk-tolerance dial, not a performance knob. A value outside that range refuses boot, naming the variable. It bounds how much recent history has no external evidence yet:

The sweep is incremental, so short intervals stay cheap on the database; the cost of tightening is the permanent Object Lock object count.

Verify and monitor

Posture at a glance is the vault.anchoring block of GET /v1/admin/ops-summary: enabled, intervalMinutes, bucket, instanceId, and workerEnabled with reconciled, which say whether the worker that runs the sweep agrees with the API. reconciled: false means the two processes disagree on their environment; the worker is the one that decides whether anchoring happens.

The admin anchor calls below take a platform key with the admin:system scope. List a record's anchors (recordId is required; with anchoring off the list is empty):

curl -H "Authorization: Bearer $AGLEDGER_PLATFORM_KEY" \
  "$AGLEDGER_API_URL/v1/admin/vault/anchors?recordId=<uuid>"

Each entry gives the object key, its chainPosition, lastModified, size, the number of stored versions, and hidden: true when a delete marker hides the key.

Verify anchors against the database (exposes any divergence between the live chain and the pinned state). Without chainPosition in the body it checks the record's latest 10 checkpoints:

curl -X POST -H "Authorization: Bearer $AGLEDGER_PLATFORM_KEY" -H "Content-Type: application/json" \
  -d '{"recordId": "<uuid>"}' \
  "$AGLEDGER_API_URL/v1/admin/vault/anchors/verify"
{"data":[{"recordId":"019fe911-...","chainPosition":1,"match":true,"outcome":"verified",
  "detail":"Anchor matches database checkpoint","storedVersions":1,"versionsNote":null}],
 "nextSteps":[...]}

outcome is one of verified, tamper, fork, superseded, hidden, no_such_key, missing_or_hidden, empty_body, s3_error, no_checkpoint or anchoring_disabled. Only verified answers match: true, and only tamper, fork, hidden and empty_body are findings. Branch on outcome, not on match alone.

An anchor that is a different signed checkpoint over the same chain tip (one re-created for its position after a restore, signed at a different time) is verified, because the chain both commit to is the same. A tamper or fork whose anchor was signed for an issuer other than this Server's current AGLEDGER_EXTERNAL_URL carries it as signedForIssuer, and detail names both issuers.

A fork is two anchor documents of the key's own position (this Server's instance, the record and position the key names) that commit to different chain tips. Anything else a key holds, such as a version that is not JSON, another position's or another instance's document, or the same document twice, is not a fork and refuses nothing; detail names those versions. Verify reads up to 25 versions of a key, newest first. A key holding more than one version that it could not read in full (more versions than that, or versions the store refused because the process lacks s3:GetObjectVersion) is reported as a fork, because a fork among the unread versions cannot be ruled out; reconcile reads further. Anchors are not checked for a valid signature before they count as evidence, so a well-formed document with a different chain tip, written by anything that can write to the bucket, is reported and refuses writes like any other.

A daily background job, on the worker, verifies a random sample of 20 checkpoints against their anchors (checkpoints not yet anchored are skipped, so a run may verify fewer than 20) and compares the bucket against the database the same way POST /v1/admin/vault/anchors/reconcile does. That daily job is the unattended tamper detector. The worker also runs the same bucket comparison once at its own boot, so a rewind between backup and restore surfaces at startup rather than waiting for the clock. Three Prometheus counters back alerting:

When the store cannot be read, the anchor listing and the reconciliation answer 503 with type: /problems/anchor-store-unavailable, naming the bucket, the endpoint and what the store answered; retryable is false for a refusal (credentials, permissions, a missing bucket). Chain writes are unaffected: the worker keeps checkpoints queued until the store takes them.

The first two counters can go silent rather than record error. A store the S3 client cannot reach at all, or a failure before the anchor key is even resolved, throws before any outcome is counted. An absent series where the sweep or the daily job should have run is as much a signal as outcome="error". The checkpoints whose anchor has not landed stay queued and are retried every sweep, and two gauges count them whatever the counters saw: agledger_vault_anchor_pending and agledger_vault_anchor_pending_oldest_age_seconds (both 0 while anchoring is off). The shipped AGLedgerVaultAnchorsNotLanding alert fires when that age passes twice the anchor interval plus an hour. Nothing shipped covers a daily sample-verify job that stops running; watch for the verify counter not updating on its cadence.

Uploads are deliberately non-blocking: an S3 outage never stops notarization. The counter is the signal that anchors are not landing.

With anchoring disabled, the verify endpoint still answers rather than erroring: it returns outcome: "anchoring_disabled" and match: false, with detail: "Vault anchoring is not enabled". Read that as configuration, not as a tamper finding.

In-database checkpoints (anchored or not) are readable at GET /v1/audit-vault/checkpoints. Full request/response shapes are in the API reference.

Reconcile the bucket against the database

anchors/verify starts from the checkpoints the database holds, so it cannot see a position that is in the bucket and no longer in the database. That is exactly the shape a restore from an older backup leaves behind, and POST /v1/admin/vault/anchors/reconcile is the call that catches it: it walks this Server's anchor prefix in the bucket and compares the highest anchored position per record against the current chain head.

curl -X POST -H "Authorization: Bearer $AGLEDGER_PLATFORM_KEY" \
  "$AGLEDGER_API_URL/v1/admin/vault/anchors/reconcile"

It reports rewound for an anchored position past this database's chain head, missing_locally for a record the bucket holds anchors for and this database has no entries and no checkpoints for at all, and a posture note on whether the store honours create-only writes and reports Object Lock enabled. The walk stops at a key cap and a time budget (maxKeys and deadlineMs in the body set them); a walk that stops early says truncated: true and why, so read that before reading the report as clean. Either finding refuses every chain write with 409 CHAIN_REWIND_DETECTED until an operator acknowledges it at POST /v1/admin/vault/rewind/acknowledge; GET /v1/admin/vault/rewind carries the evidence in the meantime. The Server runs the same comparison at worker boot and daily, and restore.sh runs it before it starts a restored Server, so an anchored install checks itself rather than waiting to be asked. The recovery runbook covers the acknowledge step.

Every anchor under this Server's prefix whose document parses and agrees with the key it was found at is evidence, whatever issuer its checkpoint names and whichever key signed it. One signed for an issuer other than this Server's current AGLEDGER_EXTERNAL_URL (compared as URLs, so a trailing slash is not a difference) is a rewound, missing_locally or fork finding like any other and refuses writes. The finding carries signedForIssuer, and the log names both issuers: another Server may share AGLEDGER_INSTANCE_ID, or the external URL changed. The way out is the acknowledgement, or fixing the configuration. The per-install instance id is what keeps two Servers sharing a bucket apart.

Limitations