Skip to content

proven on the hosted testnet instance · 2026-09-13 · QA-14

Store, read back, and know when it is certified

A bucket that belongs to your tenant. An object stored in it and read back with the same bytes you sent. A HEAD that tells you where that object stands — and, once it is certified, names the blob it was certified as. Then the object gone, with a 404 where it was.

You will also know how to ask “is it certified yet?” without guessing, which is the only part of this that is not plain S3.

A bucket is private to the tenant that created it. Twelve operations check that ownership, so another tenant asking to write, read, list or delete inside yours is refused — see Tenancy.

10-bucket.sh
# Create the bucket. 200 means it exists and it is yours.
curl -sS -m 60 -X PUT -H "$AUTH" \
-o /dev/null -w 'bucket %{http_code}\n' "$HOST/$BUCKET"

200 means it exists and it is yours.

An ordinary S3 PUT. Nothing about the request asks for encryption, provenance or certification — all three happen on the way in.

11-put.sh
# Store a local file as report.bin. Bring your own bytes, or make 64 KiB.
[ -f local.bin ] || head -c 65536 /dev/urandom > local.bin
curl -sS -m 120 -X PUT -H "$AUTH" --data-binary @local.bin \
-o /dev/null -w 'put %{http_code}\n' "$HOST/$BUCKET/report.bin"

The bytes are encrypted at rest, per tenant, by default. You never see that on this path: what you PUT is what you GET. The only way to opt out is an explicit header, which is Encryption at rest.

The assertion that matters. Not “it answered 200” — the same bytes.

12-get.sh
# Read it back and compare it to what you sent.
curl -sS -m 120 -H "$AUTH" -o roundtrip.bin "$HOST/$BUCKET/report.bin"
cmp local.bin roundtrip.bin && echo byte-equal

cmp is silent and the line reads byte-equal, or something went wrong and you want to know now rather than after a thousand objects.

HEAD carries two headers of Permafrost’s own. This block prints them, then polls until the second one appears.

13-head.sh
# HEAD carries Permafrost's own metadata. The sync state is always there. The
# blob id appears only once the object is certified, so its absence is
# "not yet certified", not an error.
HEADERS=$(curl -sS -m 60 -I -H "$AUTH" "$HOST/$BUCKET/report.bin")
printf '%s\n' "$HEADERS" | grep -i '^x-amz-meta-permafrost-'
WAIT_SECS=${BLOBID_WAIT_SECS:-300}
echo "waiting up to ${WAIT_SECS}s for the blob id"
WAITED=0
BLOB_ID=""
while [ "$WAITED" -lt "$WAIT_SECS" ]; do
HEADERS=$(curl -sS -m 60 -I -H "$AUTH" "$HOST/$BUCKET/report.bin")
BLOB_ID=$(printf '%s\n' "$HEADERS" \
| grep -i '^x-amz-meta-permafrost-blob-id:' | cut -d' ' -f2- | tr -d '\r' || true)
if [ -n "$BLOB_ID" ]; then break; fi
sleep 5
WAITED=$((WAITED + 5))
done
echo "blob id: ${BLOB_ID:-not yet certified} (after ${WAITED}s)"

x-amz-meta-permafrost-sync-state is always there. It is the object’s position in its own lifecycle: PENDING, UPLOADING, SYNCED, FAILED or DELETED. A fresh object is not SYNCED yet, and that is the normal state for a moment after a write.

x-amz-meta-permafrost-blob-id is there only once the object is certified. It appears when the row has reached SYNCED and carries a blob id — both conditions, so you are never handed a locator that does not resolve. Its absence is the “not yet” signal, not an error. Poll HEAD until it shows up, which is exactly what the loop above does.

That is the whole protocol for “has it landed”: ask HEAD again. There is no callback, no queue to watch and no separate status route to learn.

Once it has landed, the object also has a receipt on Sui. Finding it is Read your receipt on Sui.

14-delete.sh
# Delete it. Expect 204, then 404 on the read path.
curl -sS -m 60 -X DELETE -H "$AUTH" \
-o /dev/null -w 'delete %{http_code}\n' "$HOST/$BUCKET/report.bin"
curl -sS -m 60 -H "$AUTH" \
-o /dev/null -w 'get after delete %{http_code}\n' "$HOST/$BUCKET/report.bin"

204 on the delete, then 404 on the read path. Those two lines are the check.

The deleted object also drops out of the keyless verify answer: asking Verify a file by hash for its hash no longer returns provenance, unless some other object you still hold has the same bytes.

Neither of those two headers is emitted for a multipart object. A multipart object’s bytes resolve through its upload’s manifest and part rows rather than through a single blob, so a HEAD on one carries no sync state and no blob id — not “not yet”, but never. If you are uploading media in parts, read Upload media in parts, read by range instead, and do not build a wait loop around headers that will not arrive.

Permafrost runs on Sui testnet and Walrus testnet. Everything here describes a shipped testnet instance, not a production service.