Published in openvibe-contracts v0.114.0 (docs/adr/ADR-031-s3-compatible-surface.md), rendered as is.

ADR-031: An S3-compatible surface for OpenVibe.Media

Status: Accepted 2026-09-26 (roadmap WS-N task 8). Implementation follows in OpenVibe.Media. Amended 2026-10-02: named object zones (OpenVibe.Zone). A bucket is a named zone inside a project environment, resolved per access key. The environment's own namespace is the zone default.

Context and current evidence

Decision

Alternatives considered

Migration consequences

A new vhost, a signature verifier and access-key issuance in Codes (contract media.access-key@1, capability media.object.s3). No change to existing objects.

Rollback

Turn the vhost off and revoke the keys. Objects written through it stay ordinary Media objects.

Acceptance tests

Amendment 2026-10-02: named object zones (OpenVibe.Zone)

Status: Accepted for implementation planning 2026-10-02 (plan D37; the Zone research report, build step 1). It serves ADR-034 sections 2, 3 and 9. It keeps ADR-035's ownership rule. Nothing here is built. Contracts, all planned: zone.object-zone@1 and its create, update, list, list-query, delete-query and usage contracts, with the Zone service manifest and its planned capabilities.

Context

OpenVibe.Zone (openvibe.zone, formerly "Data") promises developers named, private storage zones per project and environment. The Decision above fixed one bucket per project environment and rejected free-form bucket names, because the namespace is the unit of ownership, quota and grants. Without this amendment there would be two object catalogs, two quotas and two deletion paths for the same bytes.

Evidence on 2026-10-02:

Decision

1. A zone is a named Media namespace, so there is still one object catalog.

2. Ownership.

| Concern | Owner | |---|---| | The zone record: id, name, environment, state, access policy, per-zone quota setting, the usage view, control API, resource index entry | Zone, in its own PostgreSQL database (ADR-035). Zone never reads Media's database. | | Objects, keys, bytes, placement and replicas, hashes, holds, derivatives, the S3 and native data APIs, signing, quota enforcement at write time, the counting of usage, and deletion of every copy | Media | | Projects, environments, members, project quotas, grants and their resource scopes, revocation | Network (ADR-014, ADR-034 section 3) | | S3 access keys: issuance, show-once secret, revocation | Codes, as decided above, with a zone scope added | | Rating usage against the price list | Billing (ADR-034 section 9) | | Display and control in the console | Services, only through Zone's API |

3. Names.

4. The default zone.

5. Control API (Zone). Base https://api.openvibe.zone/api/v1, authorised with Network tokens. Each route has one planned capability in the Zone service manifest (manifests/services/zone.json) and named request and response contracts:

| Route | Capability | Request | Answer | |---|---|---|---| | POST /projects/{project_id}/zones | zone.zone.create | body zone.object-zone-create-request@1, Idempotency-Key header | 201 zone.object-zone@1 | | GET /projects/{project_id}/zones | zone.zone.list | query zone.object-zone-list-query@1 (env, include_deleted, cursor, limit) | 200 zone.object-zone-list@1 | | GET /zones/{zone_id} | zone.zone.read | none | 200 zone.object-zone@1 | | PATCH /zones/{zone_id} | zone.zone.manage | body zone.object-zone-update-request@1 (quota_bytes, description only) | 200 zone.object-zone@1 | | DELETE /zones/{zone_id} | zone.zone.delete | query zone.object-zone-delete-query@1 (recursive) | 202 zone.object-zone@1 in state deleting | | GET /zones/{zone_id}/usage | zone.usage.read | none | 200 zone.object-zone-usage@1 (see 8) |

Zone provisions each change in Media synchronously:

6. Data APIs (Media). They change only as follows:

7. Scoped grants.

8. Quotas and usage attribution.

9. Private access and signed URLs.

10. One byte and deletion lifecycle, including replicas. It is the lifecycle media.object.delete already promises (a soft delete, a restore within the retention period, and a refusal under a retention hold), extended to every door and every copy. Every way an object can be removed goes through Media's one deletion path:

An object has three states on this path: live, tombstoned (restorable until its purge_after) and purged (every copy erased).

  1. Holds refuse first. A caller's deletion of an object under a retention hold (ADR-006) is refused, through every door: the native API as media.object.delete does today, and S3 DeleteObject and overwrite with AccessDenied. A held object stays live and readable. Zone deletion is refused the same way (409 zone.held, see 5).
  2. Tombstone (soft delete). In one transaction, the object's lifecycle_status becomes deleted, purge_after is set, media.object.deleted is emitted through the outbox, and the object stops counting. From then on every door answers "not found": S3 NoSuchKey, the native API 404, presigned URLs and signed downloads.
  1. Restore. Until purge_after, POST /api/v2/:app/objects/:id/restore makes the object live again, checked against the namespace's and the zone's quotas, as today. It is refused while the object's zone is deleting or deleted, and with 409 when a live object of the zone now holds its key (only an S3 write or an overwrite can cause that, because derived keys never collide). S3 has no restore. An object deleted over S3 is restored through the native API.
  2. Purge, fastest copies first. After purge_after:
  1. Holds placed later. A hold placed on a tombstoned object before it is purged delays the purge only: the object stays "not found" and uncounted, cannot be restored past its purge_after, and is purged when the hold clears. ADR-033's rules for held media apply to account deletion.
  2. The record. The purged row, without bytes, is kept for audit. The account-deletion rules of ADR-033 apply to any subject fields.

Zone deletion uses the same path:

  1. Zone checks with Media that no object of the zone is held (409 zone.held otherwise), then sets the zone to deleting and pushes that to Media synchronously. From then on, data requests answer NoSuchBucket.
  2. Media tombstones every live object of the namespace in batches, with purge_after at the tombstone, and purges every object of the namespace, including those tombstoned earlier whose retention period has not ended.
  3. Through the internal endpoint, Media reports objects_remaining and held_objects.
  4. When no object remains and every copy is confirmed gone, Zone marks the zone deleted. Its name is retired, never freed (see 3), so no later zone can accept a request signed for this one (see 9).
  5. Codes revokes keys scoped only to that zone. Network drops grants scoped to its resource name. Ids are never reused, so a leftover grant can never match a later zone.

A hold placed on an object of the zone after that check is still allowed: Media still tombstones the object, and the hold delays only its purge (step 5 above). It keeps the zone in deleting, with held_objects above 0, until the hold clears.

11. Export. The S3 and native read APIs are the export path for a zone. ADR-033's account export lists a subject's projects' zones by name and resource name. It does not copy their bytes.

Compatibility and migration

Rollback

Acceptance tests (in addition to those above)