openvibers/openvibe.contracts Commits
Add named object zone contracts with cross-field zone and usage checks (#11)
Changes
@@@ -4,6 +4,40 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) thfrom `https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/<tag>`. Before v0.30.0,the notes were in the tag and commit messages (`git tag -n1`).++## Unreleased++++**Named object zones for OpenVibe.Zone** (ADR-031 amendment 2026-10-02; plan D37). The amendment maps named Zone buckets++onto Media's S3 surface. A zone is a Media namespace, and Media keeps the one object catalog and the only deletion++path.++- **Ownership:** Zone owns the zone record and Media owns objects, bytes and replicas.++- **Names:** zone `zon_<ULID>`, `ovrn:zone:<project_id>:object-zone/<zone_id>` and Media namespace++ `<environment root>.<zone_id>`.++- **Buckets:** the bucket is the zone name, resolved within the signing key's project and environment. The environment++ root is the zone `default`, so no existing object moves.++- **Grants and quotas:** grants are scoped to a zone's resource name. A per-zone byte quota sits under the environment++ quota.++- **Usage:** Media emits per-zone usage rollups that name the zone's resource name in a new optional `resource` field++ of `common.usage-recorded@1` 1.1.0 (additive; existing producers are unchanged) and its id as `dimension`.++ `contracts.usage.checkUsageRecorded` refuses a rollup whose `resource` names another project than `project_id`, and++ `contracts.zones.checkObjectZoneUsage` a usage answer whose `ovrn` names another zone than `zone_id`.++- **Deletion:** one lifecycle for every door, the one `media.object.delete` already promises: a held object's deletion++ is refused, a tombstone is restorable until the retention period ends, and the purge then erases every replica, B2++ file versions included, within 24 hours. Zone deletion tombstones with no restore window and answers `409 zone.held`++ while an object is held.++- **No name reuse:** a `deleting` or `deleted` zone's name is retired in its environment (`409 zone.name_retired`; `provisioning`++ and `active` answer `409 zone.name_taken`), so a signature,++ which covers the key and the bucket name, always names one zone id, whatever signing time it carries.++ `contracts.zones.checkObjectZoneList` refuses a list in which two zones of one environment share a name.++- **List query:** `limit` is 1 to 200 as an integer or as a query-string value.++- **Migration:** Media backfills a key derived from each object's namespace and `med_` id, legacy child namespaces++ included, before S3 accepts writes. The default zone's access is `per-object`, so existing public and unlisted++ objects keep their URLs and cached copies.++- **Control API:** a planned Zone service manifest (`manifests/services/zone.json`) with six planned capabilities, one per++ route. The capabilities enter `generated/openapi` when they turn active.++- **Contracts:** new `zone.object-zone@1` and its create, update, list, list-query, delete-query and usage contracts,++ all `planned`, with fixtures. `contracts.zones.checkObjectZone` also checks that a zone's OVRN and Media namespace++ name its own project and id. Additive; no active contract changes.++## 0.86.0 — 2026-10-03**Harness offers for Fabric routing** (plan T16 step S5): OpenVibe.Codes publishes its harness catalog as@@@ -189,6 +189,12 @@ and `tools.net.probe` are `planned` until Tools serves them.express, such as `run.path` being the tool's own, and check each example's input against an embeddedinput schema. `jobInput(d, input)` builds a job tool's job input.++`contracts.zones.checkObjectZone(z)`, `checkObjectZoneList(list)` and `checkObjectZoneUsage(u)` add the++cross-field rules of the planned object zones (ADR-031 amendment 2026-10-02): `ovrn` and `media_namespace`++name the zone's own project and id, a name belongs to one zone of its environment for ever, and a usage++answer's `ovrn` names its `zone_id`. `contracts.usage.checkUsageRecorded(u)` refuses a usage rollup whose++`resource` names another project than its `project_id`.++Tools' problem codes are listed with the routes that answer them (`tools.run-request@1`,`tools.job-request@1`, `tools.tool-list@1`, `tools.tool@1`). They include `400 tools.query.invalid`,`404 tools.tool.not_found`, `413 tools.pdf.too_many_pages`, `422 tools.pdf.wrong_password`,@@@ -221,7 +221,7 @@},{"id": "common.usage-recorded",-- "version": "1.0.0",++ "version": "1.1.0","owner": "network","schema": "common/usage-recorded.v1.json","visibility": "first-party",@@@ -5487,6 +5487,76 @@"compatibility": "backward","status": "active","adr": "ADR-034"++ },++ {++ "id": "zone.object-zone",++ "version": "1.0.0",++ "owner": "zone",++ "schema": "zone/object-zone.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "planned",++ "adr": "ADR-031"++ },++ {++ "id": "zone.object-zone-create-request",++ "version": "1.0.0",++ "owner": "zone",++ "schema": "zone/object-zone-create-request.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "planned",++ "adr": "ADR-031"++ },++ {++ "id": "zone.object-zone-update-request",++ "version": "1.0.0",++ "owner": "zone",++ "schema": "zone/object-zone-update-request.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "planned",++ "adr": "ADR-031"++ },++ {++ "id": "zone.object-zone-list-query",++ "version": "1.0.0",++ "owner": "zone",++ "schema": "zone/object-zone-list-query.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "planned",++ "adr": "ADR-031"++ },++ {++ "id": "zone.object-zone-list",++ "version": "1.0.0",++ "owner": "zone",++ "schema": "zone/object-zone-list.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "planned",++ "adr": "ADR-031"++ },++ {++ "id": "zone.object-zone-delete-query",++ "version": "1.0.0",++ "owner": "zone",++ "schema": "zone/object-zone-delete-query.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "planned",++ "adr": "ADR-031"++ },++ {++ "id": "zone.object-zone-usage",++ "version": "1.0.0",++ "owner": "zone",++ "schema": "zone/object-zone-usage.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "planned",++ "adr": "ADR-031"}]}@@@ -2,7 +2,7 @@"$schema": "https://json-schema.org/draft/2020-12/schema","$id": "https://openvibe.network/contracts/common/usage-recorded.v1.json","title": "UsageRecorded",-- "description": "common.usage-recorded@1 (roadmap WS-N task 4, ADR-014): one rollup of a developer project's use of one capability in one environment over one closed window (an hour or a day), as the service that owns the capability counted it. The payload of every <service>.usage.recorded event; OpenVibe.Network adds them up per project and day for the project's dashboard on OpenVibe.Codes. The producer counts in the transaction that does its own accounting (a job's end, a stored event, a delivery attempt) and writes the rollup to its outbox after the window has closed: never one event per request. Totals, not deltas: a later event with the same key (source, project_id, env, capability, dimension, unit, window_start) replaces the earlier one, and a lower revision never replaces a higher one. Envelope: subject { type: project, id: <project_id> }, visibility internal, priority low, actor the producing service. Never carries a subject id, an address, a session, a request's input or content, or a file name.",++ "description": "common.usage-recorded@1 (roadmap WS-N task 4, ADR-014): one rollup of a developer project's use of one capability in one environment over one closed window (an hour or a day), as the service that owns the capability counted it. The payload of every <service>.usage.recorded event; OpenVibe.Network adds them up per project and day for the project's dashboard on OpenVibe.Codes. The producer counts in the transaction that does its own accounting (a job's end, a stored event, a delivery attempt) and writes the rollup to its outbox after the window has closed: never one event per request. Totals, not deltas: a later event with the same key (source, project_id, env, capability, resource, dimension, unit, window_start) replaces the earlier one, and a lower revision never replaces a higher one. Envelope: subject { type: project, id: <project_id> }, visibility internal, priority low, actor the producing service. Never carries a subject id, an address, a session, a request's input or content, or a file name.","type": "object","required": ["project_id", "env", "capability", "unit", "window", "window_start", "window_end", "quantity", "errors"],"additionalProperties": false,@@@ -23,6 +23,13 @@"not": { "pattern": "^(usr|gst|app|mod|svc|job|evt|req)_" },"description": "Optional finer key inside the capability, as the producer names it: a job type (img.process) or a tool id (image-resize). Never the id of a person, an app, a request or a job."},++ "resource": {++ "type": "string",++ "maxLength": 200,++ "pattern": "^ovrn:[a-z][a-z0-9-]{0,31}:prj_[0-9A-HJKMNP-TV-Z]{26}:[a-z][a-z0-9-]{0,39}/[a-z]{3}_[0-9A-HJKMNP-TV-Z]{26}$",++ "not": { "pattern": "/(usr|gst|app|mod|svc|job|evt|req)_" },++ "description": "Optional resource name (ADR-034 section 2) of the project resource the usage counts against, so Billing can rate billable usage by resource: ovrn:zone:<project_id>:object-zone/<zon_id> on Media's per-zone rollups (ADR-031 amendment 2026-10-02). Its project is project_id: a resource of another project is refused (contracts.usage.checkUsageRecorded). Added in 1.1.0. Never the resource name of a person, an app, a request or a job."++ },"unit": {"type": "string","pattern": "^[a-z][a-z0-9_]{0,31}$",nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/zone/object-zone-create-request.v1.json",++ "title": "ObjectZoneCreateRequest",++ "description": "zone.object-zone-create-request@1 (PLANNED; ADR-031 amendment 2026-10-02): the body of POST /api/v1/projects/{project_id}/zones on OpenVibe.Zone, sent with an Idempotency-Key header. The answer is 201 with a zone.object-zone@1 in state provisioning or active. A name held by a zone of that environment in state provisioning or active answers 409 zone.name_taken, and one held by a zone in state deleting or deleted answers 409 zone.name_retired, because a name belongs to one zone for ever; and going over the project's zones quota answers 403 zone.quota_exceeded. `default` cannot be requested, because Zone creates that zone with the environment.",++ "type": "object",++ "additionalProperties": false,++ "required": ["env", "name"],++ "properties": {++ "env": { "enum": ["sandbox", "production"] },++ "name": {++ "type": "string",++ "minLength": 3,++ "maxLength": 63,++ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",++ "not": { "const": "default" }++ },++ "description": { "type": ["string", "null"], "maxLength": 200 },++ "quota_bytes": { "type": ["integer", "null"], "minimum": 1 }++ }++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/zone/object-zone-delete-query.v1.json",++ "title": "ObjectZoneDeleteQuery",++ "description": "zone.object-zone-delete-query@1 (PLANNED; ADR-031 amendment 2026-10-02): the query string of DELETE /api/v1/zones/{zone_id} on OpenVibe.Zone (capability zone.zone.delete). The answer is 202 with the zone.object-zone@1 in state deleting. Without recursive=true, a zone that still holds live objects answers 409 zone.not_empty. A zone with an object under a retention hold answers 409 zone.held, as media.object.delete refuses a held object. The default zone always answers 409 zone.default. Deletion goes through Media's one deletion path: every object is tombstoned at once, with no restore window, and every copy is erased within 24 hours. A hold placed after the request delays only that object's purge. The zone's name is retired: no later zone of the environment can take it.",++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "recursive": {++ "enum": [++ true,++ false,++ "true",++ "false"++ ],++ "description": "Delete the zone's objects too. Default false."++ }++ }++}nnew file mode 100644@@@ -0,0 +1,37 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/zone/object-zone-list-query.v1.json",++ "title": "ObjectZoneListQuery",++ "description": "zone.object-zone-list-query@1 (PLANNED; ADR-031 amendment 2026-10-02): the query string of GET /api/v1/projects/{project_id}/zones on OpenVibe.Zone (capability zone.zone.list). Without env it lists both environments the caller's grant reaches. A sandbox-only grant never lists production zones.",++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "env": {++ "enum": [++ "sandbox",++ "production"++ ]++ },++ "include_deleted": {++ "enum": [++ true,++ false,++ "true",++ "false"++ ],++ "description": "Also list zones in state deleted. Default false."++ },++ "limit": {++ "oneOf": [++ { "type": "integer", "minimum": 1, "maximum": 200 },++ { "type": "string", "pattern": "^(?:[1-9][0-9]?|1[0-9]{2}|200)$" }++ ],++ "description": "Items per page, 1 to 200. A query string carries it as a string, which must name the same range."++ },++ "cursor": {++ "type": "string",++ "maxLength": 512,++ "description": "next_cursor of the previous page."++ }++ }++}nnew file mode 100644@@@ -0,0 +1,29 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/zone/object-zone-list.v1.json",++ "title": "ObjectZoneList",++ "description": "zone.object-zone-list@1 (PLANNED; ADR-031 amendment 2026-10-02): the answer of GET /api/v1/projects/{project_id}/zones on OpenVibe.Zone (capability zone.zone.list), filtered by zone.object-zone-list-query@1. Zones come oldest first, the environment's default zone before the others. Deleted zones are listed only with include_deleted=true. contracts.zones.checkObjectZoneList also checks each zone's identity. More fields may be added.",++ "type": "object",++ "additionalProperties": false,++ "required": [++ "zones",++ "next_cursor"++ ],++ "properties": {++ "zones": {++ "type": "array",++ "maxItems": 200,++ "items": {++ "$ref": "object-zone.v1.json"++ }++ },++ "next_cursor": {++ "type": [++ "string",++ "null"++ ],++ "maxLength": 512,++ "description": "Pass as cursor for the next page; null on the last page."++ }++ }++}nnew file mode 100644@@@ -0,0 +1,26 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/zone/object-zone-update-request.v1.json",++ "title": "ObjectZoneUpdateRequest",++ "description": "zone.object-zone-update-request@1 (PLANNED; ADR-031 amendment 2026-10-02): the body of PATCH /api/v1/zones/{zone_id} on OpenVibe.Zone (capability zone.zone.manage). Only quota_bytes and description change. Name, environment and access are immutable, so a body naming them is refused. The answer is 200 with the zone.object-zone@1. A quota change takes effect once Media acknowledges it, and lowering quota_bytes below the zone's stored bytes only refuses later writes. It never deletes an object.",++ "type": "object",++ "additionalProperties": false,++ "minProperties": 1,++ "properties": {++ "description": {++ "type": [++ "string",++ "null"++ ],++ "maxLength": 200++ },++ "quota_bytes": {++ "type": [++ "integer",++ "null"++ ],++ "minimum": 1,++ "description": "null removes the zone's own ceiling, so only the environment quota applies."++ }++ }++}nnew file mode 100644@@@ -0,0 +1,89 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/zone/object-zone-usage.v1.json",++ "title": "ObjectZoneUsage",++ "description": "zone.object-zone-usage@1 (PLANNED; ADR-031 amendment 2026-10-02): the answer of GET /api/v1/zones/{zone_id}/usage on OpenVibe.Zone (capability zone.usage.read). Zone never counts. current is the live-object snapshot that Media's internal zone endpoint returns. series sums Media's hourly media.usage.recorded rollups (common.usage-recorded@1) whose dimension is this zone id in lowercase, over the closed hours of the current UTC month. Billing rates the same rollups. Zone's control operations are not metered. ovrn is the OVRN of zone_id, never of another zone (contracts.zones.checkObjectZoneUsage). More fields may be added.",++ "type": "object",++ "additionalProperties": false,++ "required": [++ "zone_id",++ "ovrn",++ "current",++ "window_start",++ "window_end",++ "series"++ ],++ "properties": {++ "zone_id": {++ "type": "string",++ "pattern": "^zon_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "ovrn": {++ "type": "string",++ "pattern": "^ovrn:zone:prj_[0-9A-HJKMNP-TV-Z]{26}:object-zone/zon_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "current": {++ "type": "object",++ "additionalProperties": false,++ "required": [++ "stored_bytes",++ "objects",++ "as_of"++ ],++ "properties": {++ "stored_bytes": {++ "type": "integer",++ "minimum": 0++ },++ "objects": {++ "type": "integer",++ "minimum": 0++ },++ "as_of": {++ "type": "string",++ "format": "date-time"++ }++ }++ },++ "window_start": {++ "type": "string",++ "format": "date-time",++ "description": "The start of the current UTC month."++ },++ "window_end": {++ "type": "string",++ "format": "date-time",++ "description": "The end of the last closed hour that Media has reported."++ },++ "series": {++ "type": "array",++ "maxItems": 32,++ "items": {++ "type": "object",++ "additionalProperties": false,++ "required": [++ "capability",++ "unit",++ "quantity"++ ],++ "properties": {++ "capability": {++ "type": "string",++ "pattern": "^media\\.object\\.[a-z0-9_]+$"++ },++ "unit": {++ "enum": [++ "byte_hours",++ "requests",++ "egress_bytes"++ ]++ },++ "quantity": {++ "type": "integer",++ "minimum": 0++ }++ }++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,81 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/zone/object-zone.v1.json",++ "title": "ObjectZone",++ "description": "zone.object-zone@1 (PLANNED; ADR-031 amendment 2026-10-02): one named object zone, as OpenVibe.Zone's control API returns it (GET /api/v1/zones/{zone_id}, the list under /api/v1/projects/{project_id}/zones, and the 201 of a create). Zone owns this record: its name, environment, state, access policy and per-zone quota setting. Media owns everything inside it: the objects (ordinary med_ objects in the namespace media_namespace), their keys, bytes and replicas, quota enforcement, usage counting and the one deletion path. Zone never lists keys or holds bytes. The S3 bucket name is `name`, resolved within the project and environment of the access key that signs the request, at https://s3.openvibe.media/<name>/<key>. `ovrn` is ovrn:zone:<project_id>:object-zone/<id>, with the same project_id and id as this record. The id is never reused, and neither is a name: within one project environment a name belongs to one zone for ever, deleted or not, so a signed request names exactly one zone id. Name and env are immutable. Every environment has exactly one `default` zone, whose media_namespace is the environment root. Zone creates it with the environment, and it cannot be deleted on its own. Its access is per-object, and every other zone's is private. JSON Schema cannot compare fields: contracts.zones.checkObjectZone also requires that ovrn names this project_id and id, and that media_namespace is the environment root of project_id and env, followed by .<id> unless the zone is the default. More fields may be added.",++ "type": "object",++ "additionalProperties": false,++ "required": ["id", "ovrn", "project_id", "env", "name", "default", "access", "state", "media_namespace", "quota_bytes", "revision", "created_at"],++ "properties": {++ "id": { "type": "string", "pattern": "^zon_[0-9A-HJKMNP-TV-Z]{26}$" },++ "ovrn": { "type": "string", "pattern": "^ovrn:zone:prj_[0-9A-HJKMNP-TV-Z]{26}:object-zone/zon_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The resource name used in grants, audit, events, usage records and bills (ADR-034 section 2)." },++ "project_id": { "type": "string", "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$" },++ "env": { "enum": ["sandbox", "production"], "description": "Fixed at creation. A sandbox key never resolves a production zone." },++ "name": {++ "type": "string",++ "minLength": 3,++ "maxLength": 63,++ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",++ "description": "The S3 bucket name. It is unique among all the zones of one project environment, deleted ones included: a deleted zone's name is retired, never given to another zone. It is valid as an S3 bucket name."++ },++ "default": { "type": "boolean", "description": "The environment's default zone, named `default`. Its media_namespace is the environment root." },++ "description": { "type": ["string", "null"], "maxLength": 200 },++ "access": { "enum": ["private", "per-object"], "description": "private: every named zone in v1. Its objects never have a public_url and never enter a public cache. per-object: only the default zone, whose existing objects keep their own visibility (public, unlisted or private), public URLs and cached copies, as media.object@1 allows. The S3 surface never serves an object without a signature, in either kind of zone. Publication of a named zone would add a value in a later minor version." },++ "state": {++ "enum": ["provisioning", "active", "deleting", "deleted"],++ "description": "provisioning: Media has not yet acknowledged the zone. active: it serves requests. deleting: requests answer NoSuchBucket while Media tombstones the objects and erases every copy. deleted: no object or copy remains. The record is kept, and its name stays retired."++ },++ "media_namespace": {++ "type": "string",++ "pattern": "^app\\.prj_[0-9A-HJKMNP-TV-Z]{26}(\\.sandbox)?(\\.zon_[0-9A-HJKMNP-TV-Z]{26})?$",++ "description": "Where Media keeps the zone's objects. It is the environment root for the default zone, and <root>.<id> for every other zone."++ },++ "quota_bytes": { "type": ["integer", "null"], "minimum": 1, "description": "An optional byte ceiling for this zone. Media enforces it together with the project environment's byte quota, and a write over either is refused with QuotaExceeded. null means only the environment quota applies." },++ "usage": {++ "type": "object",++ "additionalProperties": false,++ "required": ["stored_bytes", "objects", "as_of"],++ "description": "A snapshot of the live objects, as Media reports it. Zone never counts. Billed usage comes from Media's hourly media.usage.recorded rollups, whose dimension is this id in lowercase.",++ "properties": {++ "stored_bytes": { "type": "integer", "minimum": 0 },++ "objects": { "type": "integer", "minimum": 0 },++ "as_of": { "type": "string", "format": "date-time" }++ }++ },++ "deletion": {++ "type": "object",++ "additionalProperties": false,++ "required": ["requested_at", "objects_remaining", "held_objects", "completed_at"],++ "description": "Progress of a zone deletion, as Media reports it.",++ "properties": {++ "requested_at": { "type": "string", "format": "date-time" },++ "objects_remaining": { "type": "integer", "minimum": 0, "description": "Objects not yet tombstoned, plus tombstoned objects with a copy not yet confirmed erased." },++ "held_objects": { "type": "integer", "minimum": 0, "description": "Objects whose erasure waits for a retention hold (ADR-006). They are already unreadable." },++ "completed_at": { "type": ["string", "null"], "format": "date-time" }++ }++ },++ "revision": { "type": "integer", "minimum": 1, "description": "Raised by every change. Zone sends it when it provisions the change in Media, and Media applies only a higher revision." },++ "created_at": { "type": "string", "format": "date-time" },++ "updated_at": { "type": ["string", "null"], "format": "date-time" }++ },++ "allOf": [++ {++ "if": { "properties": { "default": { "const": true } }, "required": ["default"] },++ "then": { "properties": { "name": { "const": "default" }, "access": { "const": "per-object" }, "media_namespace": { "type": "string", "pattern": "^app\\.prj_[0-9A-HJKMNP-TV-Z]{26}(\\.sandbox)?$" } } },++ "else": { "properties": { "name": { "not": { "const": "default" } }, "access": { "const": "private" }, "media_namespace": { "type": "string", "pattern": "\\.zon_[0-9A-HJKMNP-TV-Z]{26}$" } } }++ },++ {++ "if": { "properties": { "env": { "const": "sandbox" } }, "required": ["env"] },++ "then": { "properties": { "media_namespace": { "type": "string", "pattern": "^app\\.prj_[0-9A-HJKMNP-TV-Z]{26}\\.sandbox(\\.|$)" } } },++ "else": { "properties": { "media_namespace": { "type": "string", "not": { "pattern": "\\.sandbox(\\.|$)" } } } }++ },++ {++ "if": { "properties": { "state": { "enum": ["deleting", "deleted"] } }, "required": ["state"] },++ "then": { "required": ["deletion"] }++ },++ {++ "if": { "properties": { "state": { "const": "deleted" } }, "required": ["state"] },++ "then": { "properties": { "deletion": { "type": "object", "properties": { "objects_remaining": { "const": 0 }, "held_objects": { "const": 0 }, "completed_at": { "type": "string" } } } } }++ }++ ]++}@@@ -1,6 +1,6 @@# ADR-031: An S3-compatible surface for OpenVibe.Media--**Status:** Accepted 2026-09-26 (roadmap WS-N task 8). Implementation follows in 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@@@ -44,3 +44,235 @@ Turn the vhost off and revoke the keys. Objects written through it stay ordinary- A quota overrun is refused.- A presigned URL expires on time.- An object written over S3 reads through the object API with the same bytes and hash.++++## 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:++- The S3 surface is not built. Media has no S3 route.++- Contracts has no `media.access-key` contract. No access key has been issued.++- No client holds a `<project_key>` bucket name.++++### Decision++++**1. A zone is a named Media namespace, so there is still one object catalog.**++- An object zone is a named namespace inside one project environment.++- Its objects are ordinary Media objects (`med_` ids) in Media's one object table, with one lifecycle. Zone never lists, stores or copies keys or bytes.++- The rule of the Decision above still holds, now one level down: the namespace is the unit of ownership, quota and grants, and a zone is a namespace.++++**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.**++- **Zone id:** `zon_<ULID>`. It is never reused.++- **Resource name:** `ovrn:zone:<project_id>:object-zone/<zone_id>`. It is used in grants, audit, events, usage and bills.++ - The environment is an immutable attribute of the zone and a grant condition. It is not part of the name.++ - Objects keep their Media resource name, `ovrn:media:<project_id>:object/<med_id>`. Media records the object's `zone_id`.++- **Zone name:**++ - 3 to 63 characters of `a-z`, `0-9` and single inner hyphens. A valid name is also a valid S3 bucket name.++ - It is unique among **all** the zones of one project environment, `deleted` ones included. A name is never given to a second zone of that environment: once its zone is `deleting` or `deleted`, the name is retired, and creating a zone with it answers `409 zone.name_retired`.++ - It is immutable. There is no rename, because the name is the bucket that clients configure.++ - The name `default` is reserved (see 4).++ - So within one project environment a name identifies exactly one zone id, for ever. This is what binds a signed request to one zone (see 9). S3 lets a deleted bucket's name be taken again, and a URL signed for the old bucket then reaches the new one; retiring names closes that.++- **Media namespace:** `<environment root>.<zone_id>`, for example `app.prj_01J….sandbox.zon_01K…`. The namespace uses the id, never the name, so Media never depends on a name.++- **One identity.** `ovrn` and `media_namespace` are derived from `id`, `project_id`, `env` and `default`. They are never set on their own.++ - JSON Schema cannot compare two fields, so `contracts.zones.checkObjectZone(z)` adds the equality checks to the schema: the OVRN names the record's project and id, and the namespace is the record's environment root, plus `.<id>` unless the zone is the default.++ - Zone runs the check on every record before it stores or returns it. Media runs it on every provision call and refuses a mismatch, so a projection row can never point one zone's name at another zone's namespace.++- **Bucket name:** the zone name, resolved within the **project and environment of the access key** that signed the request.++ - Two projects can each have a bucket named `assets`. So can one project's sandbox and its production.++ - A key can only ever resolve its own environment's zones.++ - Media's projection keeps `deleted` zones, so a retired name resolves to its deleted zone (`NoSuchBucket`), never to another one.++ - This replaces the global `<project_key>` and `<project_key>-sandbox` bucket names in the Decision above. Those names were never issued.++- **Object key:**++ - The key is the S3 key: UTF-8, 1 to 1,024 bytes. It is unique among the live objects of one zone.++ - An object created through the native API without a key gets a derived key (see 4). This keeps "same objects, two doors" true.++++**4. The default zone.**++- Every project environment has one zone named `default`. Its Media namespace is the **environment root itself**.++- Zone creates it with the environment. Zone backfills it for existing projects.++- Existing objects in the environment root, and in any sub-namespace that is not a zone (a legacy child namespace), belong to it. No object moves and no namespace string changes.++- **Derived keys.** Media gives every object without a key a key derived from its namespace and its `med_` id:++ - an object in the environment root gets `<med_id>`;++ - an object in a legacy child namespace `<root>.<seg1>.<seg2>` gets `<seg1>/<seg2>/<med_id>`, so `ListObjectsV2` with delimiter `/` shows that namespace as a common prefix.++ - The backfill (see Compatibility and migration) derives keys for every row, tombstoned rows included, and finishes before the S3 vhost accepts a write. No client-chosen key exists yet, and `med_` ids are unique and never reused, so derived keys cannot collide.++ - After that, a keyless native create checks the key's uniqueness in the transaction that creates the object. If a client has already written that exact key over S3, Media takes a new `med_` id. It never overwrites.++ - An S3 write to the default zone stores the object in the environment root with the key as given. A `/` in a key never creates or names a Media namespace.++- **Legacy visibility.** The default zone's `access` is `per-object`, because existing objects may be `public` or `unlisted` (`media.object@1`):++ - Existing objects keep their `visibility`, `public_url` and cached copies. Native callers may still set `public` or `unlisted` in the default zone, as today.++ - The S3 surface never serves an object without a signature, whatever its visibility. An object written over S3 is `private`.++ - Making a public or unlisted object private, or deleting it, purges its CDN and public-cache copies (see 10).++ - A project that wants a private-only bucket creates a named zone. Turning a default zone private is a later, explicit migration per project, which would set each object private and purge it. It never happens silently.++- It cannot be deleted on its own. It goes when the environment or the project is deleted.++- A grant on the default zone covers the environment root's subtree except the `zon_` sub-namespaces. That is the only boundary rule Media adds.++++**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) |++++- Create answers by the state of the zone of the environment that has the name: `409 zone.name_taken` if it is `provisioning` or `active`, `409 zone.name_retired` if it is `deleting` or `deleted`. It answers `403 zone.quota_exceeded` past the project's `zones` quota. Zone enforces this quota, and deleted zones do not count against it.++- Name and environment are immutable. An update that names them is refused by its schema.++- Delete answers `409 zone.not_empty` for a zone that still holds live objects, unless the caller sends `recursive=true`. It answers `409 zone.held` while any object of the zone is under a retention hold, as `media.object.delete` refuses a held object. The default zone answers `409 zone.default`.++- `GET /resources` lists entries of kind `zone.object-zone` (`common.resource-summary@1`).++- Errors are `errors.problem@1`.++- The capabilities are `planned`. `lib/openapi.js` builds `generated/openapi` from active capabilities only, so these routes enter `generated/openapi/zone.json` in the release that implements them and turns the capabilities active. Each capability already names its routes and contracts.++++Zone provisions each change in Media **synchronously**:++- Zone calls Media's internal zone endpoint (service token, planned capability `media.zone.provision`, granted only to Zone).++- Each call carries the zone's `revision`. Media applies only a higher revision.++- A zone becomes `active`, or a quota change takes effect, only after Media acknowledges it.++- Media keeps a projection of the zone (id, project, environment, name, namespace, quota, state, revision). It uses the projection to resolve bucket names and enforce quotas.++- An hourly reconciliation compares Zone's list with Media's projection. A difference is an alert, and Zone's record wins.++++**6. Data APIs (Media).** They change only as follows:++- **S3** at `https://s3.openvibe.media`, path style `/<zone name>/<key>`:++ - `ListBuckets` lists the active zones of the key's environment.++ - `HeadBucket` is added to the supported operations, because SDKs and rclone probe with it.++ - `CreateBucket` and `DeleteBucket` stay `NotImplemented`. Zones are created and deleted only through Zone's API, so policy and quota live in one place.++ - An unknown bucket, or a zone that is `deleting` or `deleted`, answers `NoSuchBucket`.++- **Native object API** (`/api/v2/:app/objects`): a request that names a zone's namespace reads and writes that zone.++- **Overwrite:** a `PutObject` to an existing key creates a new object and deletes the previous one through the deletion path in section 10. There is no versioning in v1. An overwrite of an object under a retention hold is refused with `AccessDenied`, as its deletion would be.++++**7. Scoped grants.**++- The data capabilities stay Media's: `media.object.read`, `.list`, `.upload`, `.delete` and `media.object.s3`.++- Their resource scope (ADR-034 section 3) may name:++ - one zone, `ovrn:zone:<project_id>:object-zone/<zone_id>`;++ - every zone of the project, `ovrn:zone:<project_id>:object-zone/*`.++- Media maps a zone's resource name to its namespace through the projection. Environment is a grant condition, and a sandbox credential never reaches a production zone.++- Access keys (`media.access-key@1`, still to be written) gain an optional `zones` list of zone ids.++ - An empty list means the whole environment, as the Decision above describes.++ - A key scoped to zones cannot list, read, sign for or write any other zone.++- An environment-wide grant (namespace = environment root) keeps meaning "as the project's app" and covers every zone in that environment.++++**8. Quotas and usage attribution.**++- **Bytes:**++ - The project environment's byte quota (Network, ADR-014) is the ceiling.++ - A zone may set a lower `quota_bytes`. The sum of zone quotas may exceed the environment quota, because the environment quota is still enforced.++ - Media checks both limits in the same request that completes a write. Either refusal is `QuotaExceeded` (403), as decided above.++ - Only live objects count. An object counts as soon as it completes and stops counting at its tombstone, even before its copies are erased.++- **Zone count:** the project's `zones` quota is enforced by Zone.++- **Counting:** **Media is the only counter.** Per zone and per closed hour it emits `media.usage.recorded` (`common.usage-recorded@1`, planned for Media). Every rollup names the zone twice:++ - `resource` is the zone's resource name, `ovrn:zone:<project_id>:object-zone/<zone_id>`, as ADR-034 section 9 requires of billable usage. Billing rates by it and the console links it.++ - `dimension` is the zone id in lowercase (`zon_01k…`), for consumers that only group by dimension.++ - Rollups for the default zone carry the default zone's resource name too. A rollup without `resource` is never Media's.++- The series are:++ - stored bytes: capability `media.object.upload`, unit `byte_hours`;++ - write and list operations: each capability, unit `requests`;++ - read operations: `media.object.read`, unit `requests`;++ - egress: `media.object.read`, unit `egress_bytes`.++- **Zone's usage view:**++ - Zone sums Media's rollups for the zone, plus a current-bytes snapshot that Media's internal zone endpoint returns.++ - Zone never counts bytes itself.++ - Zone's control operations are not metered (ADR-034 section 9: a project's internal traffic is free).++++**9. Private access and signed URLs.**++- Named zones are `private` only in v1. The default zone is `per-object` (see 4).++ - A named zone's object never has a `public_url`, never enters a public cache, and refuses visibility `public` or `unlisted`.++ - Publishing a zone through the CDN (research build step 6) is a later amendment. It would add an `access` value to `zone.object-zone@1`.++- Presigned SigV4 `GET` and `PUT` URLs are signed by the client with a zone-scoped or environment-wide key and are valid for at most 7 days.++- Media checks the signature, the key's scope, the zone state and the quota **when the URL is used**, not when it is signed. A URL stops working as soon as any of these happens:++ - the key is revoked;++ - the zone becomes `deleting`;++ - the object is deleted.++- **A signed request is bound to one immutable zone id.** The signature covers the access key id, which fixes the project and environment, and the path, which names the bucket. A name names one zone id in its environment for ever (see 3). So every signature resolves to the zone id it was made for, and to no other:++ - this holds whatever signing time (`X-Amz-Date`) the client chose, past or future. Time is never what binds a URL to a zone;++ - once that zone is `deleting` or `deleted`, the URL answers `NoSuchBucket` until it expires, and no later zone can take its name;++ - a zone-scoped key names zone ids, so it cannot reach another zone either.++- Media also refuses with `AccessDenied` a request whose signing time is more than 15 minutes in the future, as S3 does. This limits clock skew; it is not what binds a request to a zone.++- A presigned URL names a key, not a version. After an overwrite it serves the new object, as S3 does.++- A caller that holds only an app token uses Media's native signed download for an `med_` id. Zone never signs and never serves bytes.++++**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**:++- S3 `DeleteObject`;++- native `DELETE` (`media.object.delete`);++- an overwrite;++- zone deletion;++- environment, project or account deletion (ADR-033).++++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.++ - For S3 `DeleteObject`, native `DELETE` and an overwrite, `purge_after` is the end of the namespace's retention period, as today.++ - For zone deletion, and for environment, project or account deletion, `purge_after` is the tombstone itself. These deletions are not restorable.++3. **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.++4. **Purge, fastest copies first.** After `purge_after`:++ - an object that is or was `public` or `unlisted` first has its CDN and public-cache copies purged. Only the default zone can hold one (see 4). A named zone's objects are never in the CDN. If a later public zone is added, its objects are purged here too;++ - the R2 hot cache and the local asset-origin and cache copies are removed, then the B2 canonical copy. For B2, every file version is deleted (`b2_delete_file_version`). A hide marker is not deletion;++ - a copy is marked removed in `media_locations` only after the provider confirms it is gone (a `HEAD` answers not found).++ - Every copy is erased within 24 hours of `purge_after`. A sweeper retries failed removals. A location still present after the deadline raises an operator alert.++5. **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.++6. **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++++- **Contracts:** the new `zone.*` contracts, the Zone service manifest and its capabilities are additive and `planned`. No active contract changes.++ - When Media implements this amendment, `media.object@1` gains optional `zone_id` and `key` in a minor version. Its description already allows added fields.++ - `media.access-key@1` is written with the `zones` scope from the start.++ - `common.usage-recorded@1` gains an optional `resource` (a resource name). It is additive: existing producers do not send it, and their rollups are unchanged.++- **Media, expand/migrate/contract (ADR-028):**++ - expand: a nullable `zone_id` and `key` on objects, and the zone projection table;++ - migrate: Zone creates each environment's default zone and pushes it. Media then backfills `zone_id` and the derived `key` (see 4) for every row, before the S3 vhost accepts writes. Metadata only: no object moves, no namespace changes, and no visibility changes;++ - contract: `zone_id` and `key` become required for developer-project tenants, with a unique index on (`zone_id`, `key`) over live objects.++ - First-party `:app` tenants (for example `community`) are not zones and do not change.++- **Clients:** existing native-API callers keep working unchanged. Their namespaces are the default zone, and their objects keep their visibility, public URLs and cached copies (see 4).++ - Native `DELETE` and restore keep the contract of `media.object.delete`: a soft delete for the retention period, a restore within it, and a refusal under a hold (see 10). What is new is that the purge after the retention period is defined to erase every copy.++ - The one new refusal is a restore whose key a live object now holds. Only an S3 write can cause it, so a caller that never uses S3 never meets it.++++### Rollback++++- Turn off Zone's control API. Media keeps serving every zone from its projection. Zone's database holds no bytes and no object list.++- If Zone's database is lost, its zone records can be rebuilt from Media's projection (id, project, environment, name, quota, state). Zone remains the authority once it is restored.++- Turning off the S3 vhost (the Rollback above) leaves zones reachable through the native API.++++### Acceptance tests (in addition to those above)++++- **Two doors:** one object written over S3 to zone `assets` reads through the native API with the same `med_` id, bytes and hash, and the reverse.++ - Deleting it through either door makes both doors and a presigned URL answer "not found".++ - Every location row is confirmed removed. The B2 copy has no remaining file version.++- **Isolation:** two projects each create a zone named `assets`, and each key sees only its own. A sandbox key cannot resolve a production zone. A key scoped to zone A gets `AccessDenied` on zone B.++- **Quotas:** a write over the zone's `quota_bytes` is refused. So is a write within the zone's quota that is over the environment quota.++- **Zone deletion:**++ - `DELETE` on a non-empty zone without `recursive` answers `409 zone.not_empty`. On a zone with a held object it answers `409 zone.held`.++ - With `recursive`, the zone answers `NoSuchBucket` at once, every object is tombstoned with no restore window, and every copy is erased within the deadline.++ - Creating a zone with the deleted zone's name, while it is `deleting` and after it is `deleted`, answers `409 zone.name_retired`.++- **Holds:** a native `DELETE`, an S3 `DeleteObject` and an S3 overwrite of a held object are each refused, and the object stays readable. A hold placed on a tombstoned object keeps its canonical copy until the hold clears, while every door answers "not found".++- **Restore:** an object deleted over S3 is restored within the retention period through the native API and reads over S3 again with the same bytes. After `purge_after`, no copy remains.++- **Default zone:**++ - After the backfill, existing root-namespace objects list in bucket `default` under their `med_` id, and objects of a legacy child namespace list under its `<seg>/` prefix, without any move. Every derived key is unique.++ - A keyless native create whose derived key a client already wrote over S3 gets a new `med_` id and overwrites nothing.++ - A grant on the default zone does not reach a `zon_` sub-namespace.++- **Legacy visibility:** a public object in the default zone keeps its `public_url` after the migration. An unsigned S3 `GetObject` for it answers `AccessDenied`. Deleting it purges its CDN copy.++- **No name reuse, whatever the signing time:** presign a `GET` for zone `assets` with an `X-Amz-Date` 10 minutes in the future, and another with the current time. Delete `assets`. Both URLs answer `NoSuchBucket`, inside their 7 days and after the future date has passed. Creating a new `assets` in that environment answers `409 zone.name_retired`. The same name in the other environment, or in another project, is a different bucket and accepts neither URL.++- **Identity:** `contracts.zones.checkObjectZone` refuses a record whose OVRN or namespace names another zone or project. Media refuses such a provision call.++- **Usage:** every Media usage rollup carries the zone's resource name as `resource` and its id as `dimension`. Their stored-byte total matches Media's live bytes for the zone.++- **Reconciliation:** an injected mismatch between Zone and Media's projection raises the alert.@@@ -34,7 +34,7 @@ Platform-wide decisions from the OpenVibe development roadmap (section 10). Each| [ADR-028](ADR-028-expand-migrate-contract.md) | Expand, migrate, contract: schema changes that survive a rollback (7-day window); destructive-statement check and rollback-with-newer-writes test | Accepted 2026-09-25; enforced in Live || [ADR-029](ADR-029-account-merge.md) | Merging two accounts: survivor keeps the subject, the other becomes an alias, services follow `network.subject.merged` | Accepted 2026-09-26; implementation pending || [ADR-030](ADR-030-follow-graph.md) | Follow graph: Network owns follows by subject, Live keeps a projection; expand/migrate/contract with reconciliation | Accepted 2026-09-26; migration pending |--| [ADR-031](ADR-031-s3-compatible-surface.md) | S3-compatible subset for Media: bucket = project namespace, object CRUD + ListObjectsV2 + presign, per-project keys | Accepted 2026-09-26; implementation pending |++| [ADR-031](ADR-031-s3-compatible-surface.md) | S3-compatible subset for Media: bucket = project namespace, object CRUD + ListObjectsV2 + presign, per-project keys | Accepted 2026-09-26; implementation pending; amended 2026-10-02: named object zones. Zone owns the zone record, and Media keeps the one object catalog and the only deletion path. Bucket = zone name resolved per access key; the environment root is the `default` zone; a deleted zone's name is retired || [ADR-032](ADR-032-containers.md) | Containers: platform services stay systemd units (one adapter in ovhost, a reserved `runtime` field); tenant code isolation belongs to Stage C | Accepted 2026-09-26 || [ADR-033](ADR-033-account-export-and-deletion.md) | Account export and deletion: services push their export parts to Network; deletion after a 30-day grace, each service erases its rows and confirms; money rows kept pseudonymised | Accepted 2026-09-27; implementation in progress || [ADR-034](ADR-034-platform-north-star.md) | Platform north star, an open affordable cloud: boundaries now (projects, `ovrn:` resource names, scoped grants, generated contracts, metering), infrastructure when measured; control/data plane and cells; regions, edge nodes, load balancing and a geo API; pulled, gated deploys; four extension levels under one manifest | Proposed 2026-09-28, for the owner's review |nnew file mode 100644@@@ -0,0 +1,13 @@++{++ "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4",++ "env": "production",++ "capability": "media.object.upload",++ "resource": "ovrn:network:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:member/usr_01K2A9C3D5E7F9G1H3J5K7M9N1",++ "dimension": "zon_01k2a9c3d5e7f9g1h3j5k7m9n1",++ "unit": "byte_hours",++ "window": "hour",++ "window_start": "2026-10-02T09:00:00.000Z",++ "window_end": "2026-10-02T10:00:00.000Z",++ "quantity": 3774873600,++ "errors": 0++}nnew file mode 100644@@@ -0,0 +1,13 @@++{++ "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4",++ "env": "production",++ "capability": "media.object.upload",++ "resource": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y9:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1",++ "dimension": "zon_01k2a9c3d5e7f9g1h3j5k7m9n1",++ "unit": "byte_hours",++ "window": "hour",++ "window_start": "2026-10-02T09:00:00.000Z",++ "window_end": "2026-10-02T10:00:00.000Z",++ "quantity": 3774873600,++ "errors": 0++}nnew file mode 100644@@@ -0,0 +1,13 @@++{++ "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4",++ "env": "production",++ "capability": "media.object.upload",++ "resource": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1",++ "dimension": "zon_01k2a9c3d5e7f9g1h3j5k7m9n1",++ "unit": "byte_hours",++ "window": "hour",++ "window_start": "2026-10-02T09:00:00.000Z",++ "window_end": "2026-10-02T10:00:00.000Z",++ "quantity": 3774873600,++ "errors": 0++}nnew file mode 100644@@@ -0,0 +1,13 @@++{++ "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4",++ "env": "production",++ "capability": "media.object.upload",++ "resource": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1",++ "dimension": "zon_01k2a9c3d5e7f9g1h3j5k7m9n1",++ "unit": "byte_hours",++ "window": "hour",++ "window_start": "2026-10-02T09:00:00.000Z",++ "window_end": "2026-10-02T10:00:00.000Z",++ "quantity": 3774873600,++ "errors": 0++}nnew file mode 100644@@@ -0,0 +1 @@++{ "env": "production", "name": "my--zone" }nnew file mode 100644@@@ -0,0 +1 @@++{ "env": "production", "name": "scratch", "access": "public" }nnew file mode 100644@@@ -0,0 +1 @@++{ "env": "production", "name": "default" }nnew file mode 100644@@@ -0,0 +1 @@++{ "env": "production", "name": "ab" }nnew file mode 100644@@@ -0,0 +1 @@++{ "env": "staging", "name": "scratch" }nnew file mode 100644@@@ -0,0 +1 @@++{ "env": "sandbox", "name": "scratch" }nnew file mode 100644@@@ -0,0 +1 @@++{ "env": "production", "name": "agent-artifacts", "description": "Run outputs", "quota_bytes": 5368709120 }nnew file mode 100644@@@ -0,0 +1 @@++{"force": "true"}nnew file mode 100644@@@ -0,0 +1 @@++{"recursive": "yes"}nnew file mode 100644@@@ -0,0 +1 @@++{}nnew file mode 100644@@@ -0,0 +1 @@++{"recursive": "true"}nnew file mode 100644@@@ -0,0 +1 @@++{"limit": 0}nnew file mode 100644@@@ -0,0 +1 @@++{"limit": "999"}nnew file mode 100644@@@ -0,0 +1 @@++{"limit": "201"}nnew file mode 100644@@@ -0,0 +1 @@++{"limit": "0"}nnew file mode 100644@@@ -0,0 +1 @@++{"limit": 500}nnew file mode 100644@@@ -0,0 +1 @@++{"env": "staging"}nnew file mode 100644@@@ -0,0 +1 @@++{"name": "assets"}nnew file mode 100644@@@ -0,0 +1 @@++{"env": "production", "limit": "200"}nnew file mode 100644@@@ -0,0 +1 @@++{"env": "sandbox", "limit": "50", "cursor": "eyJhZnRlciI6Inpvbl8wMSJ9"}nnew file mode 100644@@@ -0,0 +1 @@++{"include_deleted": "true"}nnew file mode 100644@@@ -0,0 +1 @@++{"zones": [{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}]}nnew file mode 100644@@@ -0,0 +1 @@++{"zones": [{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}, {"id": "zon_01K1A9C3D5E7F9G1H3J5K7M9N0", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K1A9C3D5E7F9G1H3J5K7M9N0", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "access": "private", "state": "deleted", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K1A9C3D5E7F9G1H3J5K7M9N0", "quota_bytes": null, "revision": 5, "created_at": "2026-09-01T09:00:00Z", "updated_at": "2026-09-20T09:00:00Z", "deletion": {"requested_at": "2026-09-19T09:00:00Z", "objects_remaining": 0, "held_objects": 0, "completed_at": "2026-09-20T09:00:00Z"}}], "next_cursor": null}nnew file mode 100644@@@ -0,0 +1 @@++{"zones": [{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}, {"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N3", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}], "next_cursor": null}nnew file mode 100644@@@ -0,0 +1 @@++{"zones": [], "next_cursor": null}nnew file mode 100644@@@ -0,0 +1 @@++{"zones": [{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}, {"id": "zon_01K1A9C3D5E7F9G1H3J5K7M9N0", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K1A9C3D5E7F9G1H3J5K7M9N0", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "sandbox", "name": "agent-artifacts", "default": false, "access": "private", "state": "deleted", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.sandbox.zon_01K1A9C3D5E7F9G1H3J5K7M9N0", "quota_bytes": null, "revision": 5, "created_at": "2026-09-01T09:00:00Z", "updated_at": "2026-09-20T09:00:00Z", "deletion": {"requested_at": "2026-09-19T09:00:00Z", "objects_remaining": 0, "held_objects": 0, "completed_at": "2026-09-20T09:00:00Z"}}], "next_cursor": null}nnew file mode 100644@@@ -0,0 +1 @@++{"zones": [{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N2", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N2", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "sandbox", "name": "default", "default": true, "access": "per-object", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.sandbox", "quota_bytes": null, "revision": 1, "created_at": "2026-10-02T09:00:00Z"}, {"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}], "next_cursor": null}nnew file mode 100644@@@ -0,0 +1 @@++{"env": "production"}nnew file mode 100644@@@ -0,0 +1 @@++{}nnew file mode 100644@@@ -0,0 +1 @@++{"access": "public"}nnew file mode 100644@@@ -0,0 +1 @@++{"name": "new-name"}nnew file mode 100644@@@ -0,0 +1 @@++{"quota_bytes": null, "description": null}nnew file mode 100644@@@ -0,0 +1 @@++{"quota_bytes": 10737418240}nnew file mode 100644@@@ -0,0 +1 @@++{"zone_id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "window_start": "2026-10-01T00:00:00Z", "window_end": "2026-10-02T10:00:00Z", "series": [{"capability": "media.object.upload", "unit": "byte_hours", "quantity": 25165824}, {"capability": "media.object.read", "unit": "requests", "quantity": 340}, {"capability": "media.object.read", "unit": "egress_bytes", "quantity": 8388608}]}nnew file mode 100644@@@ -0,0 +1 @@++{"zone_id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N9", "current": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "window_start": "2026-10-01T00:00:00Z", "window_end": "2026-10-02T10:00:00Z", "series": [{"capability": "media.object.upload", "unit": "byte_hours", "quantity": 25165824}, {"capability": "media.object.read", "unit": "requests", "quantity": 340}, {"capability": "media.object.read", "unit": "egress_bytes", "quantity": 8388608}]}nnew file mode 100644@@@ -0,0 +1 @@++{"zone_id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "current": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "window_start": "2026-10-01T00:00:00Z", "window_end": "2026-10-02T10:00:00Z", "series": [{"capability": "media.object.read", "unit": "gigabytes", "quantity": 1}]}nnew file mode 100644@@@ -0,0 +1 @@++{"zone_id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "current": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "window_start": "2026-10-01T00:00:00Z", "window_end": "2026-10-02T10:00:00Z", "series": [{"capability": "zone.zone.create", "unit": "requests", "quantity": 1}]}nnew file mode 100644@@@ -0,0 +1 @@++{"zone_id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "current": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "window_start": "2026-10-01T00:00:00Z", "window_end": "2026-10-02T10:00:00Z", "series": [{"capability": "media.object.upload", "unit": "byte_hours", "quantity": 25165824}, {"capability": "media.object.read", "unit": "requests", "quantity": 340}, {"capability": "media.object.read", "unit": "egress_bytes", "quantity": 8388608}]}nnew file mode 100644@@@ -0,0 +1 @@++{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N2", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N2", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "sandbox", "name": "default", "default": true, "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.sandbox", "quota_bytes": null, "revision": 1, "created_at": "2026-10-02T09:00:00Z"}nnew file mode 100644@@@ -0,0 +1 @@++{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "scratch", "default": false, "access": "private", "state": "deleted", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "deletion": { "requested_at": "2026-10-02T11:00:00Z", "objects_remaining": 2, "held_objects": 0, "completed_at": "2026-10-02T12:00:00Z" }, "revision": 5, "created_at": "2026-10-02T09:00:00Z" }nnew file mode 100644@@@ -0,0 +1 @@++{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "scratch", "default": false, "access": "private", "state": "deleting", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "revision": 3, "created_at": "2026-10-02T09:00:00Z" }nnew file mode 100644@@@ -0,0 +1 @@++{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "default", "default": false, "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "revision": 1, "created_at": "2026-10-02T09:00:00Z" }nnew file mode 100644@@@ -0,0 +1 @@++{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "per-object", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}nnew file mode 100644@@@ -0,0 +1 @@++{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y5.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}nnew file mode 100644@@@ -0,0 +1 @@++{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N3", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}nnew file mode 100644@@@ -0,0 +1 @@++{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y5:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}nnew file mode 100644@@@ -0,0 +1 @@++{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N3", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}nnew file mode 100644@@@ -0,0 +1 @@++{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "site-assets", "default": false, "access": "public", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "revision": 1, "created_at": "2026-10-02T09:00:00Z" }nnew file mode 100644@@@ -0,0 +1 @@++{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "sandbox", "name": "scratch", "default": false, "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "revision": 1, "created_at": "2026-10-02T09:00:00Z" }nnew file mode 100644@@@ -0,0 +1 @@++{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "Agent_Artifacts", "default": false, "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "revision": 1, "created_at": "2026-10-02T09:00:00Z" }nnew file mode 100644@@@ -0,0 +1 @@++{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": { "stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z" }, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z" }nnew file mode 100644@@@ -0,0 +1 @@++{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N2", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N2", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "sandbox", "name": "default", "default": true, "access": "per-object", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.sandbox", "quota_bytes": null, "revision": 1, "created_at": "2026-10-02T09:00:00Z" }nnew file mode 100644@@@ -0,0 +1 @@++{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "old-backups", "default": false, "access": "private", "state": "deleted", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "deletion": { "requested_at": "2026-10-02T11:00:00Z", "objects_remaining": 0, "held_objects": 0, "completed_at": "2026-10-02T12:10:00Z" }, "revision": 5, "created_at": "2026-10-01T09:00:00Z", "updated_at": "2026-10-02T12:10:00Z" }nnew file mode 100644@@@ -0,0 +1 @@++{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "sandbox", "name": "scratch", "default": false, "access": "private", "state": "deleting", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.sandbox.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "deletion": { "requested_at": "2026-10-02T11:00:00Z", "objects_remaining": 3, "held_objects": 1, "completed_at": null }, "revision": 4, "created_at": "2026-10-01T09:00:00Z", "updated_at": "2026-10-02T11:00:00Z" }@@@ -2924,14 +2924,14 @@}},"common.usage-recorded": {-- "version": "1.0.0",++ "version": "1.1.0","owner": "network","status": "active","schema": {"$schema": "https://json-schema.org/draft/2020-12/schema","$id": "https://openvibe.network/contracts/common/usage-recorded.v1.json","title": "UsageRecorded",-- "description": "common.usage-recorded@1 (roadmap WS-N task 4, ADR-014): one rollup of a developer project's use of one capability in one environment over one closed window (an hour or a day), as the service that owns the capability counted it. The payload of every <service>.usage.recorded event; OpenVibe.Network adds them up per project and day for the project's dashboard on OpenVibe.Codes. The producer counts in the transaction that does its own accounting (a job's end, a stored event, a delivery attempt) and writes the rollup to its outbox after the window has closed: never one event per request. Totals, not deltas: a later event with the same key (source, project_id, env, capability, dimension, unit, window_start) replaces the earlier one, and a lower revision never replaces a higher one. Envelope: subject { type: project, id: <project_id> }, visibility internal, priority low, actor the producing service. Never carries a subject id, an address, a session, a request's input or content, or a file name.",++ "description": "common.usage-recorded@1 (roadmap WS-N task 4, ADR-014): one rollup of a developer project's use of one capability in one environment over one closed window (an hour or a day), as the service that owns the capability counted it. The payload of every <service>.usage.recorded event; OpenVibe.Network adds them up per project and day for the project's dashboard on OpenVibe.Codes. The producer counts in the transaction that does its own accounting (a job's end, a stored event, a delivery attempt) and writes the rollup to its outbox after the window has closed: never one event per request. Totals, not deltas: a later event with the same key (source, project_id, env, capability, resource, dimension, unit, window_start) replaces the earlier one, and a lower revision never replaces a higher one. Envelope: subject { type: project, id: <project_id> }, visibility internal, priority low, actor the producing service. Never carries a subject id, an address, a session, a request's input or content, or a file name.","type": "object","required": ["project_id",@@@ -2974,6 +2974,15 @@},"description": "Optional finer key inside the capability, as the producer names it: a job type (img.process) or a tool id (image-resize). Never the id of a person, an app, a request or a job."},++ "resource": {++ "type": "string",++ "maxLength": 200,++ "pattern": "^ovrn:[a-z][a-z0-9-]{0,31}:prj_[0-9A-HJKMNP-TV-Z]{26}:[a-z][a-z0-9-]{0,39}/[a-z]{3}_[0-9A-HJKMNP-TV-Z]{26}$",++ "not": {++ "pattern": "/(usr|gst|app|mod|svc|job|evt|req)_"++ },++ "description": "Optional resource name (ADR-034 section 2) of the project resource the usage counts against, so Billing can rate billable usage by resource: ovrn:zone:<project_id>:object-zone/<zon_id> on Media's per-zone rollups (ADR-031 amendment 2026-10-02). Its project is project_id: a resource of another project is refused (contracts.usage.checkUsageRecorded). Added in 1.1.0. Never the resource name of a person, an app, a request or a job."++ },"unit": {"type": "string","pattern": "^[a-z][a-z0-9_]{0,31}$",@@@ -61121,6 +61130,580 @@}}}++ },++ "zone.object-zone": {++ "version": "1.0.0",++ "owner": "zone",++ "status": "planned",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/zone/object-zone.v1.json",++ "title": "ObjectZone",++ "description": "zone.object-zone@1 (PLANNED; ADR-031 amendment 2026-10-02): one named object zone, as OpenVibe.Zone's control API returns it (GET /api/v1/zones/{zone_id}, the list under /api/v1/projects/{project_id}/zones, and the 201 of a create). Zone owns this record: its name, environment, state, access policy and per-zone quota setting. Media owns everything inside it: the objects (ordinary med_ objects in the namespace media_namespace), their keys, bytes and replicas, quota enforcement, usage counting and the one deletion path. Zone never lists keys or holds bytes. The S3 bucket name is `name`, resolved within the project and environment of the access key that signs the request, at https://s3.openvibe.media/<name>/<key>. `ovrn` is ovrn:zone:<project_id>:object-zone/<id>, with the same project_id and id as this record. The id is never reused, and neither is a name: within one project environment a name belongs to one zone for ever, deleted or not, so a signed request names exactly one zone id. Name and env are immutable. Every environment has exactly one `default` zone, whose media_namespace is the environment root. Zone creates it with the environment, and it cannot be deleted on its own. Its access is per-object, and every other zone's is private. JSON Schema cannot compare fields: contracts.zones.checkObjectZone also requires that ovrn names this project_id and id, and that media_namespace is the environment root of project_id and env, followed by .<id> unless the zone is the default. More fields may be added.",++ "type": "object",++ "additionalProperties": false,++ "required": [++ "id",++ "ovrn",++ "project_id",++ "env",++ "name",++ "default",++ "access",++ "state",++ "media_namespace",++ "quota_bytes",++ "revision",++ "created_at"++ ],++ "properties": {++ "id": {++ "type": "string",++ "pattern": "^zon_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "ovrn": {++ "type": "string",++ "pattern": "^ovrn:zone:prj_[0-9A-HJKMNP-TV-Z]{26}:object-zone/zon_[0-9A-HJKMNP-TV-Z]{26}$",++ "description": "The resource name used in grants, audit, events, usage records and bills (ADR-034 section 2)."++ },++ "project_id": {++ "type": "string",++ "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "env": {++ "enum": [++ "sandbox",++ "production"++ ],++ "description": "Fixed at creation. A sandbox key never resolves a production zone."++ },++ "name": {++ "type": "string",++ "minLength": 3,++ "maxLength": 63,++ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",++ "description": "The S3 bucket name. It is unique among all the zones of one project environment, deleted ones included: a deleted zone's name is retired, never given to another zone. It is valid as an S3 bucket name."++ },++ "default": {++ "type": "boolean",++ "description": "The environment's default zone, named `default`. Its media_namespace is the environment root."++ },++ "description": {++ "type": [++ "string",++ "null"++ ],++ "maxLength": 200++ },++ "access": {++ "enum": [++ "private",++ "per-object"++ ],++ "description": "private: every named zone in v1. Its objects never have a public_url and never enter a public cache. per-object: only the default zone, whose existing objects keep their own visibility (public, unlisted or private), public URLs and cached copies, as media.object@1 allows. The S3 surface never serves an object without a signature, in either kind of zone. Publication of a named zone would add a value in a later minor version."++ },++ "state": {++ "enum": [++ "provisioning",++ "active",++ "deleting",++ "deleted"++ ],++ "description": "provisioning: Media has not yet acknowledged the zone. active: it serves requests. deleting: requests answer NoSuchBucket while Media tombstones the objects and erases every copy. deleted: no object or copy remains. The record is kept, and its name stays retired."++ },++ "media_namespace": {++ "type": "string",++ "pattern": "^app\\.prj_[0-9A-HJKMNP-TV-Z]{26}(\\.sandbox)?(\\.zon_[0-9A-HJKMNP-TV-Z]{26})?$",++ "description": "Where Media keeps the zone's objects. It is the environment root for the default zone, and <root>.<id> for every other zone."++ },++ "quota_bytes": {++ "type": [++ "integer",++ "null"++ ],++ "minimum": 1,++ "description": "An optional byte ceiling for this zone. Media enforces it together with the project environment's byte quota, and a write over either is refused with QuotaExceeded. null means only the environment quota applies."++ },++ "usage": {++ "type": "object",++ "additionalProperties": false,++ "required": [++ "stored_bytes",++ "objects",++ "as_of"++ ],++ "description": "A snapshot of the live objects, as Media reports it. Zone never counts. Billed usage comes from Media's hourly media.usage.recorded rollups, whose dimension is this id in lowercase.",++ "properties": {++ "stored_bytes": {++ "type": "integer",++ "minimum": 0++ },++ "objects": {++ "type": "integer",++ "minimum": 0++ },++ "as_of": {++ "type": "string",++ "format": "date-time"++ }++ }++ },++ "deletion": {++ "type": "object",++ "additionalProperties": false,++ "required": [++ "requested_at",++ "objects_remaining",++ "held_objects",++ "completed_at"++ ],++ "description": "Progress of a zone deletion, as Media reports it.",++ "properties": {++ "requested_at": {++ "type": "string",++ "format": "date-time"++ },++ "objects_remaining": {++ "type": "integer",++ "minimum": 0,++ "description": "Objects not yet tombstoned, plus tombstoned objects with a copy not yet confirmed erased."++ },++ "held_objects": {++ "type": "integer",++ "minimum": 0,++ "description": "Objects whose erasure waits for a retention hold (ADR-006). They are already unreadable."++ },++ "completed_at": {++ "type": [++ "string",++ "null"++ ],++ "format": "date-time"++ }++ }++ },++ "revision": {++ "type": "integer",++ "minimum": 1,++ "description": "Raised by every change. Zone sends it when it provisions the change in Media, and Media applies only a higher revision."++ },++ "created_at": {++ "type": "string",++ "format": "date-time"++ },++ "updated_at": {++ "type": [++ "string",++ "null"++ ],++ "format": "date-time"++ }++ },++ "allOf": [++ {++ "if": {++ "properties": {++ "default": {++ "const": true++ }++ },++ "required": [++ "default"++ ]++ },++ "then": {++ "properties": {++ "name": {++ "const": "default"++ },++ "access": {++ "const": "per-object"++ },++ "media_namespace": {++ "type": "string",++ "pattern": "^app\\.prj_[0-9A-HJKMNP-TV-Z]{26}(\\.sandbox)?$"++ }++ }++ },++ "else": {++ "properties": {++ "name": {++ "not": {++ "const": "default"++ }++ },++ "access": {++ "const": "private"++ },++ "media_namespace": {++ "type": "string",++ "pattern": "\\.zon_[0-9A-HJKMNP-TV-Z]{26}$"++ }++ }++ }++ },++ {++ "if": {++ "properties": {++ "env": {++ "const": "sandbox"++ }++ },++ "required": [++ "env"++ ]++ },++ "then": {++ "properties": {++ "media_namespace": {++ "type": "string",++ "pattern": "^app\\.prj_[0-9A-HJKMNP-TV-Z]{26}\\.sandbox(\\.|$)"++ }++ }++ },++ "else": {++ "properties": {++ "media_namespace": {++ "type": "string",++ "not": {++ "pattern": "\\.sandbox(\\.|$)"++ }++ }++ }++ }++ },++ {++ "if": {++ "properties": {++ "state": {++ "enum": [++ "deleting",++ "deleted"++ ]++ }++ },++ "required": [++ "state"++ ]++ },++ "then": {++ "required": [++ "deletion"++ ]++ }++ },++ {++ "if": {++ "properties": {++ "state": {++ "const": "deleted"++ }++ },++ "required": [++ "state"++ ]++ },++ "then": {++ "properties": {++ "deletion": {++ "type": "object",++ "properties": {++ "objects_remaining": {++ "const": 0++ },++ "held_objects": {++ "const": 0++ },++ "completed_at": {++ "type": "string"++ }++ }++ }++ }++ }++ }++ ]++ }++ },++ "zone.object-zone-create-request": {++ "version": "1.0.0",++ "owner": "zone",++ "status": "planned",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/zone/object-zone-create-request.v1.json",++ "title": "ObjectZoneCreateRequest",++ "description": "zone.object-zone-create-request@1 (PLANNED; ADR-031 amendment 2026-10-02): the body of POST /api/v1/projects/{project_id}/zones on OpenVibe.Zone, sent with an Idempotency-Key header. The answer is 201 with a zone.object-zone@1 in state provisioning or active. A name held by a zone of that environment in state provisioning or active answers 409 zone.name_taken, and one held by a zone in state deleting or deleted answers 409 zone.name_retired, because a name belongs to one zone for ever; and going over the project's zones quota answers 403 zone.quota_exceeded. `default` cannot be requested, because Zone creates that zone with the environment.",++ "type": "object",++ "additionalProperties": false,++ "required": [++ "env",++ "name"++ ],++ "properties": {++ "env": {++ "enum": [++ "sandbox",++ "production"++ ]++ },++ "name": {++ "type": "string",++ "minLength": 3,++ "maxLength": 63,++ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",++ "not": {++ "const": "default"++ }++ },++ "description": {++ "type": [++ "string",++ "null"++ ],++ "maxLength": 200++ },++ "quota_bytes": {++ "type": [++ "integer",++ "null"++ ],++ "minimum": 1++ }++ }++ }++ },++ "zone.object-zone-update-request": {++ "version": "1.0.0",++ "owner": "zone",++ "status": "planned",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/zone/object-zone-update-request.v1.json",++ "title": "ObjectZoneUpdateRequest",++ "description": "zone.object-zone-update-request@1 (PLANNED; ADR-031 amendment 2026-10-02): the body of PATCH /api/v1/zones/{zone_id} on OpenVibe.Zone (capability zone.zone.manage). Only quota_bytes and description change. Name, environment and access are immutable, so a body naming them is refused. The answer is 200 with the zone.object-zone@1. A quota change takes effect once Media acknowledges it, and lowering quota_bytes below the zone's stored bytes only refuses later writes. It never deletes an object.",++ "type": "object",++ "additionalProperties": false,++ "minProperties": 1,++ "properties": {++ "description": {++ "type": [++ "string",++ "null"++ ],++ "maxLength": 200++ },++ "quota_bytes": {++ "type": [++ "integer",++ "null"++ ],++ "minimum": 1,++ "description": "null removes the zone's own ceiling, so only the environment quota applies."++ }++ }++ }++ },++ "zone.object-zone-list-query": {++ "version": "1.0.0",++ "owner": "zone",++ "status": "planned",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/zone/object-zone-list-query.v1.json",++ "title": "ObjectZoneListQuery",++ "description": "zone.object-zone-list-query@1 (PLANNED; ADR-031 amendment 2026-10-02): the query string of GET /api/v1/projects/{project_id}/zones on OpenVibe.Zone (capability zone.zone.list). Without env it lists both environments the caller's grant reaches. A sandbox-only grant never lists production zones.",++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "env": {++ "enum": [++ "sandbox",++ "production"++ ]++ },++ "include_deleted": {++ "enum": [++ true,++ false,++ "true",++ "false"++ ],++ "description": "Also list zones in state deleted. Default false."++ },++ "limit": {++ "oneOf": [++ {++ "type": "integer",++ "minimum": 1,++ "maximum": 200++ },++ {++ "type": "string",++ "pattern": "^(?:[1-9][0-9]?|1[0-9]{2}|200)$"++ }++ ],++ "description": "Items per page, 1 to 200. A query string carries it as a string, which must name the same range."++ },++ "cursor": {++ "type": "string",++ "maxLength": 512,++ "description": "next_cursor of the previous page."++ }++ }++ }++ },++ "zone.object-zone-list": {++ "version": "1.0.0",++ "owner": "zone",++ "status": "planned",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/zone/object-zone-list.v1.json",++ "title": "ObjectZoneList",++ "description": "zone.object-zone-list@1 (PLANNED; ADR-031 amendment 2026-10-02): the answer of GET /api/v1/projects/{project_id}/zones on OpenVibe.Zone (capability zone.zone.list), filtered by zone.object-zone-list-query@1. Zones come oldest first, the environment's default zone before the others. Deleted zones are listed only with include_deleted=true. contracts.zones.checkObjectZoneList also checks each zone's identity. More fields may be added.",++ "type": "object",++ "additionalProperties": false,++ "required": [++ "zones",++ "next_cursor"++ ],++ "properties": {++ "zones": {++ "type": "array",++ "maxItems": 200,++ "items": {++ "$ref": "object-zone.v1.json"++ }++ },++ "next_cursor": {++ "type": [++ "string",++ "null"++ ],++ "maxLength": 512,++ "description": "Pass as cursor for the next page; null on the last page."++ }++ }++ }++ },++ "zone.object-zone-delete-query": {++ "version": "1.0.0",++ "owner": "zone",++ "status": "planned",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/zone/object-zone-delete-query.v1.json",++ "title": "ObjectZoneDeleteQuery",++ "description": "zone.object-zone-delete-query@1 (PLANNED; ADR-031 amendment 2026-10-02): the query string of DELETE /api/v1/zones/{zone_id} on OpenVibe.Zone (capability zone.zone.delete). The answer is 202 with the zone.object-zone@1 in state deleting. Without recursive=true, a zone that still holds live objects answers 409 zone.not_empty. A zone with an object under a retention hold answers 409 zone.held, as media.object.delete refuses a held object. The default zone always answers 409 zone.default. Deletion goes through Media's one deletion path: every object is tombstoned at once, with no restore window, and every copy is erased within 24 hours. A hold placed after the request delays only that object's purge. The zone's name is retired: no later zone of the environment can take it.",++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "recursive": {++ "enum": [++ true,++ false,++ "true",++ "false"++ ],++ "description": "Delete the zone's objects too. Default false."++ }++ }++ }++ },++ "zone.object-zone-usage": {++ "version": "1.0.0",++ "owner": "zone",++ "status": "planned",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/zone/object-zone-usage.v1.json",++ "title": "ObjectZoneUsage",++ "description": "zone.object-zone-usage@1 (PLANNED; ADR-031 amendment 2026-10-02): the answer of GET /api/v1/zones/{zone_id}/usage on OpenVibe.Zone (capability zone.usage.read). Zone never counts. current is the live-object snapshot that Media's internal zone endpoint returns. series sums Media's hourly media.usage.recorded rollups (common.usage-recorded@1) whose dimension is this zone id in lowercase, over the closed hours of the current UTC month. Billing rates the same rollups. Zone's control operations are not metered. ovrn is the OVRN of zone_id, never of another zone (contracts.zones.checkObjectZoneUsage). More fields may be added.",++ "type": "object",++ "additionalProperties": false,++ "required": [++ "zone_id",++ "ovrn",++ "current",++ "window_start",++ "window_end",++ "series"++ ],++ "properties": {++ "zone_id": {++ "type": "string",++ "pattern": "^zon_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "ovrn": {++ "type": "string",++ "pattern": "^ovrn:zone:prj_[0-9A-HJKMNP-TV-Z]{26}:object-zone/zon_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "current": {++ "type": "object",++ "additionalProperties": false,++ "required": [++ "stored_bytes",++ "objects",++ "as_of"++ ],++ "properties": {++ "stored_bytes": {++ "type": "integer",++ "minimum": 0++ },++ "objects": {++ "type": "integer",++ "minimum": 0++ },++ "as_of": {++ "type": "string",++ "format": "date-time"++ }++ }++ },++ "window_start": {++ "type": "string",++ "format": "date-time",++ "description": "The start of the current UTC month."++ },++ "window_end": {++ "type": "string",++ "format": "date-time",++ "description": "The end of the last closed hour that Media has reported."++ },++ "series": {++ "type": "array",++ "maxItems": 32,++ "items": {++ "type": "object",++ "additionalProperties": false,++ "required": [++ "capability",++ "unit",++ "quantity"++ ],++ "properties": {++ "capability": {++ "type": "string",++ "pattern": "^media\\.object\\.[a-z0-9_]+$"++ },++ "unit": {++ "enum": [++ "byte_hours",++ "requests",++ "egress_bytes"++ ]++ },++ "quantity": {++ "type": "integer",++ "minimum": 0++ }++ }++ }++ }++ }++ }}}}@@@ -4691,9 +4691,9 @@ export interface ModerationAction {details?: {};}--/** [email protected] (owner: network) */++/** [email protected] (owner: network) *//**-- * common.usage-recorded@1 (roadmap WS-N task 4, ADR-014): one rollup of a developer project's use of one capability in one environment over one closed window (an hour or a day), as the service that owns the capability counted it. The payload of every <service>.usage.recorded event; OpenVibe.Network adds them up per project and day for the project's dashboard on OpenVibe.Codes. The producer counts in the transaction that does its own accounting (a job's end, a stored event, a delivery attempt) and writes the rollup to its outbox after the window has closed: never one event per request. Totals, not deltas: a later event with the same key (source, project_id, env, capability, dimension, unit, window_start) replaces the earlier one, and a lower revision never replaces a higher one. Envelope: subject { type: project, id: <project_id> }, visibility internal, priority low, actor the producing service. Never carries a subject id, an address, a session, a request's input or content, or a file name.++ * common.usage-recorded@1 (roadmap WS-N task 4, ADR-014): one rollup of a developer project's use of one capability in one environment over one closed window (an hour or a day), as the service that owns the capability counted it. The payload of every <service>.usage.recorded event; OpenVibe.Network adds them up per project and day for the project's dashboard on OpenVibe.Codes. The producer counts in the transaction that does its own accounting (a job's end, a stored event, a delivery attempt) and writes the rollup to its outbox after the window has closed: never one event per request. Totals, not deltas: a later event with the same key (source, project_id, env, capability, resource, dimension, unit, window_start) replaces the earlier one, and a lower revision never replaces a higher one. Envelope: subject { type: project, id: <project_id> }, visibility internal, priority low, actor the producing service. Never carries a subject id, an address, a session, a request's input or content, or a file name.*/export interface UsageRecorded {/**@@@ -4712,6 +4712,10 @@ export interface UsageRecorded {* Optional finer key inside the capability, as the producer names it: a job type (img.process) or a tool id (image-resize). Never the id of a person, an app, a request or a job.*/dimension?: string;++ /**++ * Optional resource name (ADR-034 section 2) of the project resource the usage counts against, so Billing can rate billable usage by resource: ovrn:zone:<project_id>:object-zone/<zon_id> on Media's per-zone rollups (ADR-031 amendment 2026-10-02). Its project is project_id: a resource of another project is refused (contracts.usage.checkUsageRecorded). Added in 1.1.0. Never the resource name of a person, an app, a request or a job.++ */++ resource?: string;/*** What quantity counts (jobs, events, deliveries, requests, bytes, tokens): the same word a Network project quota uses for its unit.*/@@@ -29742,6 +29746,10 @@ export interface ToolsUsageRecordedPayload {* Optional finer key inside the capability, as the producer names it: a job type (img.process) or a tool id (image-resize). Never the id of a person, an app, a request or a job.*/dimension?: string;++ /**++ * Optional resource name (ADR-034 section 2) of the project resource the usage counts against, so Billing can rate billable usage by resource: ovrn:zone:<project_id>:object-zone/<zon_id> on Media's per-zone rollups (ADR-031 amendment 2026-10-02). Its project is project_id: a resource of another project is refused (contracts.usage.checkUsageRecorded). Added in 1.1.0. Never the resource name of a person, an app, a request or a job.++ */++ resource?: string;/*** What quantity counts (jobs, events, deliveries, requests, bytes, tokens): the same word a Network project quota uses for its unit.*/@@@ -30706,6 +30714,10 @@ export interface EventsUsageRecordedPayload {* Optional finer key inside the capability, as the producer names it: a job type (img.process) or a tool id (image-resize). Never the id of a person, an app, a request or a job.*/dimension?: string;++ /**++ * Optional resource name (ADR-034 section 2) of the project resource the usage counts against, so Billing can rate billable usage by resource: ovrn:zone:<project_id>:object-zone/<zon_id> on Media's per-zone rollups (ADR-031 amendment 2026-10-02). Its project is project_id: a resource of another project is refused (contracts.usage.checkUsageRecorded). Added in 1.1.0. Never the resource name of a person, an app, a request or a job.++ */++ resource?: string;/*** What quantity counts (jobs, events, deliveries, requests, bytes, tokens): the same word a Network project quota uses for its unit.*/@@@ -51741,3 +51753,175 @@ export interface ConfirmationRequest {decided_at?: string;created_at: string;}++++/** [email protected] (owner: zone) */++/**++ * zone.object-zone@1 (PLANNED; ADR-031 amendment 2026-10-02): one named object zone, as OpenVibe.Zone's control API returns it (GET /api/v1/zones/{zone_id}, the list under /api/v1/projects/{project_id}/zones, and the 201 of a create). Zone owns this record: its name, environment, state, access policy and per-zone quota setting. Media owns everything inside it: the objects (ordinary med_ objects in the namespace media_namespace), their keys, bytes and replicas, quota enforcement, usage counting and the one deletion path. Zone never lists keys or holds bytes. The S3 bucket name is `name`, resolved within the project and environment of the access key that signs the request, at https://s3.openvibe.media/<name>/<key>. `ovrn` is ovrn:zone:<project_id>:object-zone/<id>, with the same project_id and id as this record. The id is never reused, and neither is a name: within one project environment a name belongs to one zone for ever, deleted or not, so a signed request names exactly one zone id. Name and env are immutable. Every environment has exactly one `default` zone, whose media_namespace is the environment root. Zone creates it with the environment, and it cannot be deleted on its own. Its access is per-object, and every other zone's is private. JSON Schema cannot compare fields: contracts.zones.checkObjectZone also requires that ovrn names this project_id and id, and that media_namespace is the environment root of project_id and env, followed by .<id> unless the zone is the default. More fields may be added.++ */++export type ObjectZone = {++ [k: string]: unknown | undefined;++} & {++ id: string;++ /**++ * The resource name used in grants, audit, events, usage records and bills (ADR-034 section 2).++ */++ ovrn: string;++ project_id: string;++ /**++ * Fixed at creation. A sandbox key never resolves a production zone.++ */++ env: "sandbox" | "production";++ /**++ * The S3 bucket name. It is unique among all the zones of one project environment, deleted ones included: a deleted zone's name is retired, never given to another zone. It is valid as an S3 bucket name.++ */++ name: string;++ /**++ * The environment's default zone, named `default`. Its media_namespace is the environment root.++ */++ default: boolean;++ description?: string | null;++ /**++ * private: every named zone in v1. Its objects never have a public_url and never enter a public cache. per-object: only the default zone, whose existing objects keep their own visibility (public, unlisted or private), public URLs and cached copies, as media.object@1 allows. The S3 surface never serves an object without a signature, in either kind of zone. Publication of a named zone would add a value in a later minor version.++ */++ access: "private" | "per-object";++ /**++ * provisioning: Media has not yet acknowledged the zone. active: it serves requests. deleting: requests answer NoSuchBucket while Media tombstones the objects and erases every copy. deleted: no object or copy remains. The record is kept, and its name stays retired.++ */++ state: "provisioning" | "active" | "deleting" | "deleted";++ /**++ * Where Media keeps the zone's objects. It is the environment root for the default zone, and <root>.<id> for every other zone.++ */++ media_namespace: string;++ /**++ * An optional byte ceiling for this zone. Media enforces it together with the project environment's byte quota, and a write over either is refused with QuotaExceeded. null means only the environment quota applies.++ */++ quota_bytes: number | null;++ /**++ * A snapshot of the live objects, as Media reports it. Zone never counts. Billed usage comes from Media's hourly media.usage.recorded rollups, whose dimension is this id in lowercase.++ */++ usage?: {++ stored_bytes: number;++ objects: number;++ as_of: string;++ };++ /**++ * Progress of a zone deletion, as Media reports it.++ */++ deletion?: {++ requested_at: string;++ /**++ * Objects not yet tombstoned, plus tombstoned objects with a copy not yet confirmed erased.++ */++ objects_remaining: number;++ /**++ * Objects whose erasure waits for a retention hold (ADR-006). They are already unreadable.++ */++ held_objects: number;++ completed_at: string | null;++ };++ /**++ * Raised by every change. Zone sends it when it provisions the change in Media, and Media applies only a higher revision.++ */++ revision: number;++ created_at: string;++ updated_at?: string | null;++};++++/** [email protected] (owner: zone) */++/**++ * zone.object-zone-create-request@1 (PLANNED; ADR-031 amendment 2026-10-02): the body of POST /api/v1/projects/{project_id}/zones on OpenVibe.Zone, sent with an Idempotency-Key header. The answer is 201 with a zone.object-zone@1 in state provisioning or active. A name held by a zone of that environment in state provisioning or active answers 409 zone.name_taken, and one held by a zone in state deleting or deleted answers 409 zone.name_retired, because a name belongs to one zone for ever; and going over the project's zones quota answers 403 zone.quota_exceeded. `default` cannot be requested, because Zone creates that zone with the environment.++ */++export interface ObjectZoneCreateRequest {++ env: "sandbox" | "production";++ name: string;++ description?: string | null;++ quota_bytes?: number | null;++}++++/** [email protected] (owner: zone) */++/**++ * zone.object-zone-update-request@1 (PLANNED; ADR-031 amendment 2026-10-02): the body of PATCH /api/v1/zones/{zone_id} on OpenVibe.Zone (capability zone.zone.manage). Only quota_bytes and description change. Name, environment and access are immutable, so a body naming them is refused. The answer is 200 with the zone.object-zone@1. A quota change takes effect once Media acknowledges it, and lowering quota_bytes below the zone's stored bytes only refuses later writes. It never deletes an object.++ */++export interface ObjectZoneUpdateRequest {++ description?: string | null;++ /**++ * null removes the zone's own ceiling, so only the environment quota applies.++ */++ quota_bytes?: number | null;++}++++/** [email protected] (owner: zone) */++/**++ * zone.object-zone-list-query@1 (PLANNED; ADR-031 amendment 2026-10-02): the query string of GET /api/v1/projects/{project_id}/zones on OpenVibe.Zone (capability zone.zone.list). Without env it lists both environments the caller's grant reaches. A sandbox-only grant never lists production zones.++ */++export interface ObjectZoneListQuery {++ env?: "sandbox" | "production";++ /**++ * Also list zones in state deleted. Default false.++ */++ include_deleted?: true | false | "true" | "false";++ /**++ * Items per page, 1 to 200. A query string carries it as a string, which must name the same range.++ */++ limit?: number | string;++ /**++ * next_cursor of the previous page.++ */++ cursor?: string;++}++++/** [email protected] (owner: zone) */++/**++ * zone.object-zone-list@1 (PLANNED; ADR-031 amendment 2026-10-02): the answer of GET /api/v1/projects/{project_id}/zones on OpenVibe.Zone (capability zone.zone.list), filtered by zone.object-zone-list-query@1. Zones come oldest first, the environment's default zone before the others. Deleted zones are listed only with include_deleted=true. contracts.zones.checkObjectZoneList also checks each zone's identity. More fields may be added.++ */++export interface ObjectZoneList {++ /**++ * @maxItems 200++ */++ zones: ObjectZone[];++ /**++ * Pass as cursor for the next page; null on the last page.++ */++ next_cursor: string | null;++}++++/** [email protected] (owner: zone) */++/**++ * zone.object-zone-delete-query@1 (PLANNED; ADR-031 amendment 2026-10-02): the query string of DELETE /api/v1/zones/{zone_id} on OpenVibe.Zone (capability zone.zone.delete). The answer is 202 with the zone.object-zone@1 in state deleting. Without recursive=true, a zone that still holds live objects answers 409 zone.not_empty. A zone with an object under a retention hold answers 409 zone.held, as media.object.delete refuses a held object. The default zone always answers 409 zone.default. Deletion goes through Media's one deletion path: every object is tombstoned at once, with no restore window, and every copy is erased within 24 hours. A hold placed after the request delays only that object's purge. The zone's name is retired: no later zone of the environment can take it.++ */++export interface ObjectZoneDeleteQuery {++ /**++ * Delete the zone's objects too. Default false.++ */++ recursive?: true | false | "true" | "false";++}++++/** [email protected] (owner: zone) */++/**++ * zone.object-zone-usage@1 (PLANNED; ADR-031 amendment 2026-10-02): the answer of GET /api/v1/zones/{zone_id}/usage on OpenVibe.Zone (capability zone.usage.read). Zone never counts. current is the live-object snapshot that Media's internal zone endpoint returns. series sums Media's hourly media.usage.recorded rollups (common.usage-recorded@1) whose dimension is this zone id in lowercase, over the closed hours of the current UTC month. Billing rates the same rollups. Zone's control operations are not metered. ovrn is the OVRN of zone_id, never of another zone (contracts.zones.checkObjectZoneUsage). More fields may be added.++ */++export interface ObjectZoneUsage {++ zone_id: string;++ ovrn: string;++ current: {++ stored_bytes: number;++ objects: number;++ as_of: string;++ };++ /**++ * The start of the current UTC month.++ */++ window_start: string;++ /**++ * The end of the last closed hour that Media has reported.++ */++ window_end: string;++ /**++ * @maxItems 32++ */++ series: {++ capability: string;++ unit: "byte_hours" | "requests" | "egress_bytes";++ quantity: number;++ }[];++}@@@ -25,6 +25,8 @@ module.exports = {serviceAuth: require('./lib/service-auth'),modules: require('./lib/modules'),tools: require('./lib/tools'),++ zones: require('./lib/zones'),++ usage: require('./lib/usage'),staff: require('./lib/staff'),openapi: require('./lib/openapi'),};nnew file mode 100644@@@ -0,0 +1,21 @@++'use strict';++/**++ * Usage rollup helpers (roadmap WS-N task 4). common.usage-recorded@1 holds every rule JSON Schema can++ * express; this check adds the one that compares two fields, so the producer (before it writes its++ * outbox) and OpenVibe.Network (before it adds a rollup up) hold a rollup to the same bar:++ *++ * contracts.usage.checkUsageRecorded(u) -> { valid, errors: [{ path, message }] }++ * and a resource names a resource of the rollup's own project_id++ */++const registry = require('./registry');++++function checkUsageRecorded(u) {++ const r = registry.validate('common.usage-recorded@1', u);++ if (!r.valid) return r;++ // ovrn:<service>:<project_id>:<type>/<id>: usage is attributed to project_id, so the resource must be one of its own.++ const errors = [];++ if (u.resource !== undefined && u.resource.split(':')[2] !== u.project_id) errors.push({ path: '/resource', message: `must name a resource of project ${u.project_id}` });++ return { valid: errors.length === 0, errors };++}++++module.exports = { checkUsageRecorded };nnew file mode 100644@@@ -0,0 +1,53 @@++'use strict';++/**++ * Object zone helpers (ADR-031 amendment 2026-10-02). zone.object-zone@1 holds every rule JSON Schema++ * can express; these checks add the ones that compare two fields, so Zone (before it stores or returns++ * a record) and Media (on every provision call) hold a zone's identity to the same bar:++ *++ * contracts.zones.checkObjectZone(z) -> { valid, errors: [{ path, message }] }++ * contracts.zones.checkObjectZoneList(l) -> the same for a zone.object-zone-list@1 answer, every item included,++ * and no name held by two zones of one project environment++ * contracts.zones.checkObjectZoneUsage(u) -> the same for a zone.object-zone-usage@1 answer: its ovrn names its zone_id++ * contracts.zones.identity(z) -> { ovrn, media_namespace } derived from project_id, env, id and default++ */++const registry = require('./registry');++++/** The OVRN and Media namespace a zone must carry, derived from the fields that define it. */++function identity(z) {++ const root = `app.${z.project_id}${z.env === 'sandbox' ? '.sandbox' : ''}`;++ return { ovrn: `ovrn:zone:${z.project_id}:object-zone/${z.id}`, media_namespace: z.default ? root : `${root}.${z.id}` };++}++++/** Every rule for one zone record: the schema's, then that ovrn and media_namespace name this zone. */++function checkObjectZone(z) {++ const r = registry.validate('zone.object-zone@1', z);++ if (!r.valid) return r;++ const want = identity(z);++ const errors = ['ovrn', 'media_namespace'].filter(k => z[k] !== want[k]).map(k => ({ path: `/${k}`, message: `must be ${want[k]}` }));++ return { valid: errors.length === 0, errors };++}++++function checkObjectZoneList(list) {++ const r = registry.validate('zone.object-zone-list@1', list);++ if (!r.valid) return r;++ const errors = [];++ list.zones.forEach((z, i) => { for (const e of checkObjectZone(z).errors) errors.push({ path: `/zones/${i}${e.path}`, message: e.message }); });++ // A name belongs to one zone of its environment for ever, deleted or not: that is what binds a signed request to one zone id.++ const owner = new Map();++ list.zones.forEach((z, i) => {++ const k = `${z.project_id}/${z.env}/${z.name}`;++ if (owner.has(k) && owner.get(k) !== z.id) errors.push({ path: `/zones/${i}/name`, message: `is retired: it belongs to ${owner.get(k)} in this environment` });++ else owner.set(k, z.id);++ });++ return { valid: errors.length === 0, errors };++}++++/** Every rule for a zone's usage answer: the schema's, then that ovrn names the zone the answer is for. */++function checkObjectZoneUsage(u) {++ const r = registry.validate('zone.object-zone-usage@1', u);++ if (!r.valid) return r;++ const errors = u.ovrn.endsWith(`:object-zone/${u.zone_id}`) ? [] : [{ path: '/ovrn', message: `must name zone ${u.zone_id}` }];++ return { valid: errors.length === 0, errors };++}++++module.exports = { checkObjectZone, checkObjectZoneList, checkObjectZoneUsage, identity };nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "id": "zone.usage.read",++ "version": "1.0",++ "owner": "zone",++ "status": "planned",++ "visibility": "public",++ "description": "PLANNED (ADR-031 amendment 2026-10-02): Read an object zone's usage: GET /api/v1/zones/:zone_id/usage. It answers Media's live-object snapshot and the month's hourly rollups. Zone never counts. It becomes active, and enters generated/openapi/zone.json, when OpenVibe.Zone implements the route.",++ "outputSchema": "zone.object-zone-usage@1",++ "permissions": [++ "zone:read"++ ],++ "resourceConstraints": [++ "project"++ ],++ "quotaClass": "zone-control",++ "events": [],++ "implementedBy": []++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "zone.zone.create",++ "version": "1.0",++ "owner": "zone",++ "status": "planned",++ "visibility": "public",++ "description": "PLANNED (ADR-031 amendment 2026-10-02): Create a named object zone in one environment of a project. POST /api/v1/projects/:project_id/zones with an Idempotency-Key header answers 201 with the zone. A name held by a provisioning or active zone of that environment answers 409 zone.name_taken, a name held by a deleting or deleted one answers 409 zone.name_retired, and going over the project's zones quota answers 403 zone.quota_exceeded. Zone provisions the zone in Media before it becomes active. It becomes active, and enters generated/openapi/zone.json, when OpenVibe.Zone implements the route.",++ "inputSchema": "zone.object-zone-create-request@1",++ "outputSchema": "zone.object-zone@1",++ "permissions": [++ "zone:write"++ ],++ "resourceConstraints": [++ "project"++ ],++ "quotaClass": "zone-control",++ "events": [],++ "implementedBy": []++}nnew file mode 100644@@@ -0,0 +1,20 @@++{++ "id": "zone.zone.delete",++ "version": "1.0",++ "owner": "zone",++ "status": "planned",++ "visibility": "public",++ "sensitive": true,++ "description": "PLANNED (ADR-031 amendment 2026-10-02): Delete an object zone: DELETE /api/v1/zones/:zone_id answers 202 with the zone in state deleting. A zone with objects needs recursive=true (else 409 zone.not_empty), a zone with a held object answers 409 zone.held, and the default zone answers 409 zone.default. Media tombstones every object at once, with no restore window, and erases every copy within 24 hours. A hold placed after the request delays only that object's purge. The name is retired, never reused. It becomes active, and enters generated/openapi/zone.json, when OpenVibe.Zone implements the route.",++ "inputSchema": "zone.object-zone-delete-query@1",++ "outputSchema": "zone.object-zone@1",++ "permissions": [++ "zone:write"++ ],++ "resourceConstraints": [++ "project"++ ],++ "quotaClass": "zone-control",++ "events": [],++ "implementedBy": []++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "zone.zone.list",++ "version": "1.0",++ "owner": "zone",++ "status": "planned",++ "visibility": "public",++ "description": "PLANNED (ADR-031 amendment 2026-10-02): List a project's object zones: GET /api/v1/projects/:project_id/zones, filtered by env and paged. It becomes active, and enters generated/openapi/zone.json, when OpenVibe.Zone implements the route.",++ "inputSchema": "zone.object-zone-list-query@1",++ "outputSchema": "zone.object-zone-list@1",++ "permissions": [++ "zone:read"++ ],++ "resourceConstraints": [++ "project"++ ],++ "quotaClass": "zone-control",++ "events": [],++ "implementedBy": []++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "zone.zone.manage",++ "version": "1.0",++ "owner": "zone",++ "status": "planned",++ "visibility": "public",++ "description": "PLANNED (ADR-031 amendment 2026-10-02): Change an object zone's quota_bytes or description: PATCH /api/v1/zones/:zone_id. Name, environment and access are immutable. A quota change takes effect once Media acknowledges it. It becomes active, and enters generated/openapi/zone.json, when OpenVibe.Zone implements the route.",++ "inputSchema": "zone.object-zone-update-request@1",++ "outputSchema": "zone.object-zone@1",++ "permissions": [++ "zone:write"++ ],++ "resourceConstraints": [++ "project"++ ],++ "quotaClass": "zone-control",++ "events": [],++ "implementedBy": []++}nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "id": "zone.zone.read",++ "version": "1.0",++ "owner": "zone",++ "status": "planned",++ "visibility": "public",++ "description": "PLANNED (ADR-031 amendment 2026-10-02): Read one object zone: GET /api/v1/zones/:zone_id. It becomes active, and enters generated/openapi/zone.json, when OpenVibe.Zone implements the route.",++ "outputSchema": "zone.object-zone@1",++ "permissions": [++ "zone:read"++ ],++ "resourceConstraints": [++ "project"++ ],++ "quotaClass": "zone-control",++ "events": [],++ "implementedBy": []++}nnew file mode 100644@@@ -0,0 +1,52 @@++{++ "version": "0.0.0",++ "contractRanges": {++ "openvibe-contracts": ">=0.87.0 <1.0.0"++ },++ "id": "zone",++ "name": "OpenVibe.Zone",++ "status": "placeholder",++ "repository": "OpenVibers/OpenVibe.Zone",++ "domains": [++ "openvibe.zone",++ "api.openvibe.zone"++ ],++ "publicOrigin": "https://api.openvibe.zone",++ "exposure": {++ "state": "placeholder",++ "publicSite": null,++ "note": "planned (ADR-031 amendment 2026-10-02); nothing is built or deployed, and no OpenVibe service serves openvibe.zone yet"++ },++ "capabilities": [++ "zone.zone.create",++ "zone.zone.list",++ "zone.zone.read",++ "zone.zone.manage",++ "zone.zone.delete",++ "zone.usage.read"++ ],++ "eventsProduced": [],++ "eventsConsumed": [],++ "namespacesOwned": [],++ "lifecycle": {++ "liveness": {++ "none": "not built yet; nothing listens on a loopback address"++ },++ "shutdown": {++ "none": "not built yet; nothing runs or drains"++ },++ "startupRecovery": {++ "none": "not built yet; nothing starts"++ },++ "rollback": {++ "none": "not built yet; nothing is released or rolled back"++ },++ "contracts": {++ "range": ">=0.87.0 <1.0.0"++ },++ "leases": {++ "none": "not built yet; nothing runs"++ }++ },++ "notes": "Named object zones per project environment (ADR-031 amendment 2026-10-02). Zone owns the zone record and its control API in its own PostgreSQL database (ADR-035). OpenVibe.Media owns the objects, bytes, replicas, S3 and native data APIs, quota enforcement, usage counting and the one deletion path. Planned: nothing is built."++}@@@ -56,7 +56,11 @@ for (const d of contracts.catalog.length ? JSON.parse(fs.readFileSync(path.join(// ── Fixtures: every contract has examples that pass and counter-examples that fail ──// A contract with a helper that holds it to more than its schema is checked with that helper, so// its invalid fixtures can break those rules too (a tool example whose input its own schema refuses).--const FULL_CHECK = { 'tools.tool': (v) => contracts.tools.checkDescriptor(v), 'tools.tool-list': (v) => contracts.tools.checkList(v) };++const FULL_CHECK = {++ 'tools.tool': (v) => contracts.tools.checkDescriptor(v), 'tools.tool-list': (v) => contracts.tools.checkList(v),++ 'zone.object-zone': (v) => contracts.zones.checkObjectZone(v), 'zone.object-zone-list': (v) => contracts.zones.checkObjectZoneList(v),++ 'zone.object-zone-usage': (v) => contracts.zones.checkObjectZoneUsage(v), 'common.usage-recorded': (v) => contracts.usage.checkUsageRecorded(v),++};for (const c of contracts.catalog) {for (const kind of ['valid', 'invalid']) {const dir = path.join(ROOT, 'fixtures', c.id, kind);@@@ -868,6 +872,8 @@ sub(path.join(__dirname, 'loyalty-policy.test.js'));sub(path.join(__dirname, 'presence-policy.test.js'));// Every OpenVibe.Sites catalog domain has one home in Contracts (plan T11 lane D).sub(path.join(__dirname, 'product-catalog.test.js'));++// Named object zones: identity, retired names, list limits and per-zone usage (ADR-031 amendment 2026-10-02).++sub(path.join(__dirname, 'zones.test.js'));// ── Generated output and compatibility gate ──────────────────────────────sub(path.join(__dirname, 'platform-fabric.test.js'));nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++// ADR-031 amendment 2026-10-02: a zone's identity, retired names, the list query's limit and Media's per-zone usage.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const c = require('../index.js');++const zones = require('../lib/zones');++const usageLib = require('../lib/usage');++const fx = (id, kind, f) => JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'fixtures', id, kind, f), 'utf8'));++++const list = fx('zone.object-zone-list', 'valid', 'two-zones.json');++const zone = list.zones[1];++assert.ok(c.zones.checkObjectZone(zone).valid, 'a named zone passes its identity check');++assert.deepStrictEqual(c.zones.identity(zone), { ovrn: zone.ovrn, media_namespace: zone.media_namespace }, 'ovrn and namespace derive from the record');++assert.ok(!c.zones.checkObjectZone({ ...zone, media_namespace: `app.${zone.project_id}.zon_01K2A9C3D5E7F9G1H3J5K7M9N9` }).valid, 'a namespace naming another zone is refused');++++// A name belongs to one zone of its environment for ever, so a signature names one zone id.++const reused = c.zones.checkObjectZoneList(fx('zone.object-zone-list', 'invalid', 'retired-name-reused.json'));++assert.ok(!reused.valid && reused.errors.some((e) => /retired/.test(e.message)), 'a deleted zone\'s name never goes to a second zone');++assert.ok(c.zones.checkObjectZoneList(fx('zone.object-zone-list', 'valid', 'same-name-other-env.json')).valid, 'the same name in the other environment is another bucket');++assert.ok(!/may be reused|name is free/.test(c.schema('zone.object-zone@1').description + JSON.stringify(c.schema('zone.object-zone@1').properties.state)), 'no contract text frees a name');++++// The query string carries limit as a string: the same 1 to 200 holds.++for (const v of [1, 200, '1', '50', '200']) assert.ok(c.validate('zone.object-zone-list-query@1', { limit: v }).valid, `limit ${JSON.stringify(v)} is accepted`);++for (const v of [0, 201, '0', '201', '999', '050', '']) assert.ok(!c.validate('zone.object-zone-list-query@1', { limit: v }).valid, `limit ${JSON.stringify(v)} is refused`);++++// Media's per-zone rollup names the zone's resource name (ADR-034 section 9).++const usage = fx('common.usage-recorded', 'valid', 'media-zone-stored-bytes.json');++assert.ok(c.validate('common.usage-recorded@1', usage).valid, 'a usage rollup carries a resource name');++assert.strictEqual(usage.resource, zone.ovrn, 'the rollup\'s resource is the zone\'s ovrn');++assert.strictEqual(usage.dimension, zone.id.toLowerCase(), 'and its dimension the zone id in lowercase');++assert.ok(usageLib.checkUsageRecorded(usage).valid, 'its resource is a resource of its own project');++const other = fx('common.usage-recorded', 'invalid', 'resource-names-other-project.json');++assert.ok(c.validate('common.usage-recorded@1', other).valid && !usageLib.checkUsageRecorded(other).valid, 'a resource of another project is refused by the cross-field check');++assert.strictEqual(c.catalog.find((e) => e.id === 'common.usage-recorded').version, '1.1.0', 'the optional resource field is a minor version');++++// The usage answer's ovrn names its own zone_id.++assert.ok(zones.checkObjectZoneUsage(fx('zone.object-zone-usage', 'valid', 'month-to-date.json')).valid, 'a usage answer naming its zone passes');++const mismatched = zones.checkObjectZoneUsage(fx('zone.object-zone-usage', 'invalid', 'ovrn-names-other-zone.json'));++assert.ok(!mismatched.valid && mismatched.errors[0].path === '/ovrn', 'a usage answer whose ovrn names another zone is refused');++console.log('zones: all checks passed');
@@ -4,6 +4,40 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) th
@@ -4,6 +4,40 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) th
from `https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/<tag>`. Before v0.30.0,
from `https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/<tag>`. Before v0.30.0,
the notes were in the tag and commit messages (`git tag -n1`).
the notes were in the tag and commit messages (`git tag -n1`).
+## Unreleased
+
+**Named object zones for OpenVibe.Zone** (ADR-031 amendment 2026-10-02; plan D37). The amendment maps named Zone buckets
+onto Media's S3 surface. A zone is a Media namespace, and Media keeps the one object catalog and the only deletion
+path.
+- **Ownership:** Zone owns the zone record and Media owns objects, bytes and replicas.
+- **Names:** zone `zon_<ULID>`, `ovrn:zone:<project_id>:object-zone/<zone_id>` and Media namespace
+ `<environment root>.<zone_id>`.
+- **Buckets:** the bucket is the zone name, resolved within the signing key's project and environment. The environment
+ root is the zone `default`, so no existing object moves.
+- **Grants and quotas:** grants are scoped to a zone's resource name. A per-zone byte quota sits under the environment
+ quota.
+- **Usage:** Media emits per-zone usage rollups that name the zone's resource name in a new optional `resource` field
+ of `common.usage-recorded@1` 1.1.0 (additive; existing producers are unchanged) and its id as `dimension`.
+ `contracts.usage.checkUsageRecorded` refuses a rollup whose `resource` names another project than `project_id`, and
+ `contracts.zones.checkObjectZoneUsage` a usage answer whose `ovrn` names another zone than `zone_id`.
+- **Deletion:** one lifecycle for every door, the one `media.object.delete` already promises: a held object's deletion
+ is refused, a tombstone is restorable until the retention period ends, and the purge then erases every replica, B2
+ file versions included, within 24 hours. Zone deletion tombstones with no restore window and answers `409 zone.held`
+ while an object is held.
+- **No name reuse:** a `deleting` or `deleted` zone's name is retired in its environment (`409 zone.name_retired`; `provisioning`
+ and `active` answer `409 zone.name_taken`), so a signature,
+ which covers the key and the bucket name, always names one zone id, whatever signing time it carries.
+ `contracts.zones.checkObjectZoneList` refuses a list in which two zones of one environment share a name.
+- **List query:** `limit` is 1 to 200 as an integer or as a query-string value.
+- **Migration:** Media backfills a key derived from each object's namespace and `med_` id, legacy child namespaces
+ included, before S3 accepts writes. The default zone's access is `per-object`, so existing public and unlisted
+ objects keep their URLs and cached copies.
+- **Control API:** a planned Zone service manifest (`manifests/services/zone.json`) with six planned capabilities, one per
+ route. The capabilities enter `generated/openapi` when they turn active.
+- **Contracts:** new `zone.object-zone@1` and its create, update, list, list-query, delete-query and usage contracts,
+ all `planned`, with fixtures. `contracts.zones.checkObjectZone` also checks that a zone's OVRN and Media namespace
+ name its own project and id. Additive; no active contract changes.
+
## 0.86.0 — 2026-10-03
## 0.86.0 — 2026-10-03
**Harness offers for Fabric routing** (plan T16 step S5): OpenVibe.Codes publishes its harness catalog as
**Harness offers for Fabric routing** (plan T16 step S5): OpenVibe.Codes publishes its harness catalog as
@@ -189,6 +189,12 @@ and `tools.net.probe` are `planned` until Tools serves them.
@@ -189,6 +189,12 @@ and `tools.net.probe` are `planned` until Tools serves them.
express, such as `run.path` being the tool's own, and check each example's input against an embedded
express, such as `run.path` being the tool's own, and check each example's input against an embedded
input schema. `jobInput(d, input)` builds a job tool's job input.
input schema. `jobInput(d, input)` builds a job tool's job input.
+`contracts.zones.checkObjectZone(z)`, `checkObjectZoneList(list)` and `checkObjectZoneUsage(u)` add the
+cross-field rules of the planned object zones (ADR-031 amendment 2026-10-02): `ovrn` and `media_namespace`
+name the zone's own project and id, a name belongs to one zone of its environment for ever, and a usage
+answer's `ovrn` names its `zone_id`. `contracts.usage.checkUsageRecorded(u)` refuses a usage rollup whose
+`resource` names another project than its `project_id`.
+
Tools' problem codes are listed with the routes that answer them (`tools.run-request@1`,
Tools' problem codes are listed with the routes that answer them (`tools.run-request@1`,
`tools.job-request@1`, `tools.tool-list@1`, `tools.tool@1`). They include `400 tools.query.invalid`,
`tools.job-request@1`, `tools.tool-list@1`, `tools.tool@1`). They include `400 tools.query.invalid`,
`404 tools.tool.not_found`, `413 tools.pdf.too_many_pages`, `422 tools.pdf.wrong_password`,
`404 tools.tool.not_found`, `413 tools.pdf.too_many_pages`, `422 tools.pdf.wrong_password`,
@@ -221,7 +221,7 @@
@@ -221,7 +221,7 @@
},
},
{
{
"id": "common.usage-recorded",
"id": "common.usage-recorded",
- "version": "1.0.0",
+ "version": "1.1.0",
"owner": "network",
"owner": "network",
"schema": "common/usage-recorded.v1.json",
"schema": "common/usage-recorded.v1.json",
"visibility": "first-party",
"visibility": "first-party",
@@ -5487,6 +5487,76 @@
@@ -5487,6 +5487,76 @@
"compatibility": "backward",
"compatibility": "backward",
"status": "active",
"status": "active",
"adr": "ADR-034"
"adr": "ADR-034"
+ },
+ {
+ "id": "zone.object-zone",
+ "version": "1.0.0",
+ "owner": "zone",
+ "schema": "zone/object-zone.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "planned",
+ "adr": "ADR-031"
+ },
+ {
+ "id": "zone.object-zone-create-request",
+ "version": "1.0.0",
+ "owner": "zone",
+ "schema": "zone/object-zone-create-request.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "planned",
+ "adr": "ADR-031"
+ },
+ {
+ "id": "zone.object-zone-update-request",
+ "version": "1.0.0",
+ "owner": "zone",
+ "schema": "zone/object-zone-update-request.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "planned",
+ "adr": "ADR-031"
+ },
+ {
+ "id": "zone.object-zone-list-query",
+ "version": "1.0.0",
+ "owner": "zone",
+ "schema": "zone/object-zone-list-query.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "planned",
+ "adr": "ADR-031"
+ },
+ {
+ "id": "zone.object-zone-list",
+ "version": "1.0.0",
+ "owner": "zone",
+ "schema": "zone/object-zone-list.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "planned",
+ "adr": "ADR-031"
+ },
+ {
+ "id": "zone.object-zone-delete-query",
+ "version": "1.0.0",
+ "owner": "zone",
+ "schema": "zone/object-zone-delete-query.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "planned",
+ "adr": "ADR-031"
+ },
+ {
+ "id": "zone.object-zone-usage",
+ "version": "1.0.0",
+ "owner": "zone",
+ "schema": "zone/object-zone-usage.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "planned",
+ "adr": "ADR-031"
}
}
]
]
}
}
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/common/usage-recorded.v1.json",
"$id": "https://openvibe.network/contracts/common/usage-recorded.v1.json",
"title": "UsageRecorded",
"title": "UsageRecorded",
- "description": "common.usage-recorded@1 (roadmap WS-N task 4, ADR-014): one rollup of a developer project's use of one capability in one environment over one closed window (an hour or a day), as the service that owns the capability counted it. The payload of every <service>.usage.recorded event; OpenVibe.Network adds them up per project and day for the project's dashboard on OpenVibe.Codes. The producer counts in the transaction that does its own accounting (a job's end, a stored event, a delivery attempt) and writes the rollup to its outbox after the window has closed: never one event per request. Totals, not deltas: a later event with the same key (source, project_id, env, capability, dimension, unit, window_start) replaces the earlier one, and a lower revision never replaces a higher one. Envelope: subject { type: project, id: <project_id> }, visibility internal, priority low, actor the producing service. Never carries a subject id, an address, a session, a request's input or content, or a file name.",
+ "description": "common.usage-recorded@1 (roadmap WS-N task 4, ADR-014): one rollup of a developer project's use of one capability in one environment over one closed window (an hour or a day), as the service that owns the capability counted it. The payload of every <service>.usage.recorded event; OpenVibe.Network adds them up per project and day for the project's dashboard on OpenVibe.Codes. The producer counts in the transaction that does its own accounting (a job's end, a stored event, a delivery attempt) and writes the rollup to its outbox after the window has closed: never one event per request. Totals, not deltas: a later event with the same key (source, project_id, env, capability, resource, dimension, unit, window_start) replaces the earlier one, and a lower revision never replaces a higher one. Envelope: subject { type: project, id: <project_id> }, visibility internal, priority low, actor the producing service. Never carries a subject id, an address, a session, a request's input or content, or a file name.",
"type": "object",
"type": "object",
"required": ["project_id", "env", "capability", "unit", "window", "window_start", "window_end", "quantity", "errors"],
"required": ["project_id", "env", "capability", "unit", "window", "window_start", "window_end", "quantity", "errors"],
"additionalProperties": false,
"additionalProperties": false,
@@ -23,6 +23,13 @@
@@ -23,6 +23,13 @@
"not": { "pattern": "^(usr|gst|app|mod|svc|job|evt|req)_" },
"not": { "pattern": "^(usr|gst|app|mod|svc|job|evt|req)_" },
"description": "Optional finer key inside the capability, as the producer names it: a job type (img.process) or a tool id (image-resize). Never the id of a person, an app, a request or a job."
"description": "Optional finer key inside the capability, as the producer names it: a job type (img.process) or a tool id (image-resize). Never the id of a person, an app, a request or a job."
},
},
+ "resource": {
+ "type": "string",
+ "maxLength": 200,
+ "pattern": "^ovrn:[a-z][a-z0-9-]{0,31}:prj_[0-9A-HJKMNP-TV-Z]{26}:[a-z][a-z0-9-]{0,39}/[a-z]{3}_[0-9A-HJKMNP-TV-Z]{26}$",
+ "not": { "pattern": "/(usr|gst|app|mod|svc|job|evt|req)_" },
+ "description": "Optional resource name (ADR-034 section 2) of the project resource the usage counts against, so Billing can rate billable usage by resource: ovrn:zone:<project_id>:object-zone/<zon_id> on Media's per-zone rollups (ADR-031 amendment 2026-10-02). Its project is project_id: a resource of another project is refused (contracts.usage.checkUsageRecorded). Added in 1.1.0. Never the resource name of a person, an app, a request or a job."
+ },
"unit": {
"unit": {
"type": "string",
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,31}$",
"pattern": "^[a-z][a-z0-9_]{0,31}$",
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/zone/object-zone-create-request.v1.json",
+ "title": "ObjectZoneCreateRequest",
+ "description": "zone.object-zone-create-request@1 (PLANNED; ADR-031 amendment 2026-10-02): the body of POST /api/v1/projects/{project_id}/zones on OpenVibe.Zone, sent with an Idempotency-Key header. The answer is 201 with a zone.object-zone@1 in state provisioning or active. A name held by a zone of that environment in state provisioning or active answers 409 zone.name_taken, and one held by a zone in state deleting or deleted answers 409 zone.name_retired, because a name belongs to one zone for ever; and going over the project's zones quota answers 403 zone.quota_exceeded. `default` cannot be requested, because Zone creates that zone with the environment.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["env", "name"],
+ "properties": {
+ "env": { "enum": ["sandbox", "production"] },
+ "name": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
+ "not": { "const": "default" }
+ },
+ "description": { "type": ["string", "null"], "maxLength": 200 },
+ "quota_bytes": { "type": ["integer", "null"], "minimum": 1 }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/zone/object-zone-delete-query.v1.json",
+ "title": "ObjectZoneDeleteQuery",
+ "description": "zone.object-zone-delete-query@1 (PLANNED; ADR-031 amendment 2026-10-02): the query string of DELETE /api/v1/zones/{zone_id} on OpenVibe.Zone (capability zone.zone.delete). The answer is 202 with the zone.object-zone@1 in state deleting. Without recursive=true, a zone that still holds live objects answers 409 zone.not_empty. A zone with an object under a retention hold answers 409 zone.held, as media.object.delete refuses a held object. The default zone always answers 409 zone.default. Deletion goes through Media's one deletion path: every object is tombstoned at once, with no restore window, and every copy is erased within 24 hours. A hold placed after the request delays only that object's purge. The zone's name is retired: no later zone of the environment can take it.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "recursive": {
+ "enum": [
+ true,
+ false,
+ "true",
+ "false"
+ ],
+ "description": "Delete the zone's objects too. Default false."
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/zone/object-zone-list-query.v1.json",
+ "title": "ObjectZoneListQuery",
+ "description": "zone.object-zone-list-query@1 (PLANNED; ADR-031 amendment 2026-10-02): the query string of GET /api/v1/projects/{project_id}/zones on OpenVibe.Zone (capability zone.zone.list). Without env it lists both environments the caller's grant reaches. A sandbox-only grant never lists production zones.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "env": {
+ "enum": [
+ "sandbox",
+ "production"
+ ]
+ },
+ "include_deleted": {
+ "enum": [
+ true,
+ false,
+ "true",
+ "false"
+ ],
+ "description": "Also list zones in state deleted. Default false."
+ },
+ "limit": {
+ "oneOf": [
+ { "type": "integer", "minimum": 1, "maximum": 200 },
+ { "type": "string", "pattern": "^(?:[1-9][0-9]?|1[0-9]{2}|200)$" }
+ ],
+ "description": "Items per page, 1 to 200. A query string carries it as a string, which must name the same range."
+ },
+ "cursor": {
+ "type": "string",
+ "maxLength": 512,
+ "description": "next_cursor of the previous page."
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/zone/object-zone-list.v1.json",
+ "title": "ObjectZoneList",
+ "description": "zone.object-zone-list@1 (PLANNED; ADR-031 amendment 2026-10-02): the answer of GET /api/v1/projects/{project_id}/zones on OpenVibe.Zone (capability zone.zone.list), filtered by zone.object-zone-list-query@1. Zones come oldest first, the environment's default zone before the others. Deleted zones are listed only with include_deleted=true. contracts.zones.checkObjectZoneList also checks each zone's identity. More fields may be added.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "zones",
+ "next_cursor"
+ ],
+ "properties": {
+ "zones": {
+ "type": "array",
+ "maxItems": 200,
+ "items": {
+ "$ref": "object-zone.v1.json"
+ }
+ },
+ "next_cursor": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 512,
+ "description": "Pass as cursor for the next page; null on the last page."
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/zone/object-zone-update-request.v1.json",
+ "title": "ObjectZoneUpdateRequest",
+ "description": "zone.object-zone-update-request@1 (PLANNED; ADR-031 amendment 2026-10-02): the body of PATCH /api/v1/zones/{zone_id} on OpenVibe.Zone (capability zone.zone.manage). Only quota_bytes and description change. Name, environment and access are immutable, so a body naming them is refused. The answer is 200 with the zone.object-zone@1. A quota change takes effect once Media acknowledges it, and lowering quota_bytes below the zone's stored bytes only refuses later writes. It never deletes an object.",
+ "type": "object",
+ "additionalProperties": false,
+ "minProperties": 1,
+ "properties": {
+ "description": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 200
+ },
+ "quota_bytes": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 1,
+ "description": "null removes the zone's own ceiling, so only the environment quota applies."
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/zone/object-zone-usage.v1.json",
+ "title": "ObjectZoneUsage",
+ "description": "zone.object-zone-usage@1 (PLANNED; ADR-031 amendment 2026-10-02): the answer of GET /api/v1/zones/{zone_id}/usage on OpenVibe.Zone (capability zone.usage.read). Zone never counts. current is the live-object snapshot that Media's internal zone endpoint returns. series sums Media's hourly media.usage.recorded rollups (common.usage-recorded@1) whose dimension is this zone id in lowercase, over the closed hours of the current UTC month. Billing rates the same rollups. Zone's control operations are not metered. ovrn is the OVRN of zone_id, never of another zone (contracts.zones.checkObjectZoneUsage). More fields may be added.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "zone_id",
+ "ovrn",
+ "current",
+ "window_start",
+ "window_end",
+ "series"
+ ],
+ "properties": {
+ "zone_id": {
+ "type": "string",
+ "pattern": "^zon_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "ovrn": {
+ "type": "string",
+ "pattern": "^ovrn:zone:prj_[0-9A-HJKMNP-TV-Z]{26}:object-zone/zon_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "current": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "stored_bytes",
+ "objects",
+ "as_of"
+ ],
+ "properties": {
+ "stored_bytes": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "objects": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "as_of": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "window_start": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The start of the current UTC month."
+ },
+ "window_end": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The end of the last closed hour that Media has reported."
+ },
+ "series": {
+ "type": "array",
+ "maxItems": 32,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "capability",
+ "unit",
+ "quantity"
+ ],
+ "properties": {
+ "capability": {
+ "type": "string",
+ "pattern": "^media\\.object\\.[a-z0-9_]+$"
+ },
+ "unit": {
+ "enum": [
+ "byte_hours",
+ "requests",
+ "egress_bytes"
+ ]
+ },
+ "quantity": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/zone/object-zone.v1.json",
+ "title": "ObjectZone",
+ "description": "zone.object-zone@1 (PLANNED; ADR-031 amendment 2026-10-02): one named object zone, as OpenVibe.Zone's control API returns it (GET /api/v1/zones/{zone_id}, the list under /api/v1/projects/{project_id}/zones, and the 201 of a create). Zone owns this record: its name, environment, state, access policy and per-zone quota setting. Media owns everything inside it: the objects (ordinary med_ objects in the namespace media_namespace), their keys, bytes and replicas, quota enforcement, usage counting and the one deletion path. Zone never lists keys or holds bytes. The S3 bucket name is `name`, resolved within the project and environment of the access key that signs the request, at https://s3.openvibe.media/<name>/<key>. `ovrn` is ovrn:zone:<project_id>:object-zone/<id>, with the same project_id and id as this record. The id is never reused, and neither is a name: within one project environment a name belongs to one zone for ever, deleted or not, so a signed request names exactly one zone id. Name and env are immutable. Every environment has exactly one `default` zone, whose media_namespace is the environment root. Zone creates it with the environment, and it cannot be deleted on its own. Its access is per-object, and every other zone's is private. JSON Schema cannot compare fields: contracts.zones.checkObjectZone also requires that ovrn names this project_id and id, and that media_namespace is the environment root of project_id and env, followed by .<id> unless the zone is the default. More fields may be added.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "ovrn", "project_id", "env", "name", "default", "access", "state", "media_namespace", "quota_bytes", "revision", "created_at"],
+ "properties": {
+ "id": { "type": "string", "pattern": "^zon_[0-9A-HJKMNP-TV-Z]{26}$" },
+ "ovrn": { "type": "string", "pattern": "^ovrn:zone:prj_[0-9A-HJKMNP-TV-Z]{26}:object-zone/zon_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The resource name used in grants, audit, events, usage records and bills (ADR-034 section 2)." },
+ "project_id": { "type": "string", "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$" },
+ "env": { "enum": ["sandbox", "production"], "description": "Fixed at creation. A sandbox key never resolves a production zone." },
+ "name": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
+ "description": "The S3 bucket name. It is unique among all the zones of one project environment, deleted ones included: a deleted zone's name is retired, never given to another zone. It is valid as an S3 bucket name."
+ },
+ "default": { "type": "boolean", "description": "The environment's default zone, named `default`. Its media_namespace is the environment root." },
+ "description": { "type": ["string", "null"], "maxLength": 200 },
+ "access": { "enum": ["private", "per-object"], "description": "private: every named zone in v1. Its objects never have a public_url and never enter a public cache. per-object: only the default zone, whose existing objects keep their own visibility (public, unlisted or private), public URLs and cached copies, as media.object@1 allows. The S3 surface never serves an object without a signature, in either kind of zone. Publication of a named zone would add a value in a later minor version." },
+ "state": {
+ "enum": ["provisioning", "active", "deleting", "deleted"],
+ "description": "provisioning: Media has not yet acknowledged the zone. active: it serves requests. deleting: requests answer NoSuchBucket while Media tombstones the objects and erases every copy. deleted: no object or copy remains. The record is kept, and its name stays retired."
+ },
+ "media_namespace": {
+ "type": "string",
+ "pattern": "^app\\.prj_[0-9A-HJKMNP-TV-Z]{26}(\\.sandbox)?(\\.zon_[0-9A-HJKMNP-TV-Z]{26})?$",
+ "description": "Where Media keeps the zone's objects. It is the environment root for the default zone, and <root>.<id> for every other zone."
+ },
+ "quota_bytes": { "type": ["integer", "null"], "minimum": 1, "description": "An optional byte ceiling for this zone. Media enforces it together with the project environment's byte quota, and a write over either is refused with QuotaExceeded. null means only the environment quota applies." },
+ "usage": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["stored_bytes", "objects", "as_of"],
+ "description": "A snapshot of the live objects, as Media reports it. Zone never counts. Billed usage comes from Media's hourly media.usage.recorded rollups, whose dimension is this id in lowercase.",
+ "properties": {
+ "stored_bytes": { "type": "integer", "minimum": 0 },
+ "objects": { "type": "integer", "minimum": 0 },
+ "as_of": { "type": "string", "format": "date-time" }
+ }
+ },
+ "deletion": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["requested_at", "objects_remaining", "held_objects", "completed_at"],
+ "description": "Progress of a zone deletion, as Media reports it.",
+ "properties": {
+ "requested_at": { "type": "string", "format": "date-time" },
+ "objects_remaining": { "type": "integer", "minimum": 0, "description": "Objects not yet tombstoned, plus tombstoned objects with a copy not yet confirmed erased." },
+ "held_objects": { "type": "integer", "minimum": 0, "description": "Objects whose erasure waits for a retention hold (ADR-006). They are already unreadable." },
+ "completed_at": { "type": ["string", "null"], "format": "date-time" }
+ }
+ },
+ "revision": { "type": "integer", "minimum": 1, "description": "Raised by every change. Zone sends it when it provisions the change in Media, and Media applies only a higher revision." },
+ "created_at": { "type": "string", "format": "date-time" },
+ "updated_at": { "type": ["string", "null"], "format": "date-time" }
+ },
+ "allOf": [
+ {
+ "if": { "properties": { "default": { "const": true } }, "required": ["default"] },
+ "then": { "properties": { "name": { "const": "default" }, "access": { "const": "per-object" }, "media_namespace": { "type": "string", "pattern": "^app\\.prj_[0-9A-HJKMNP-TV-Z]{26}(\\.sandbox)?$" } } },
+ "else": { "properties": { "name": { "not": { "const": "default" } }, "access": { "const": "private" }, "media_namespace": { "type": "string", "pattern": "\\.zon_[0-9A-HJKMNP-TV-Z]{26}$" } } }
+ },
+ {
+ "if": { "properties": { "env": { "const": "sandbox" } }, "required": ["env"] },
+ "then": { "properties": { "media_namespace": { "type": "string", "pattern": "^app\\.prj_[0-9A-HJKMNP-TV-Z]{26}\\.sandbox(\\.|$)" } } },
+ "else": { "properties": { "media_namespace": { "type": "string", "not": { "pattern": "\\.sandbox(\\.|$)" } } } }
+ },
+ {
+ "if": { "properties": { "state": { "enum": ["deleting", "deleted"] } }, "required": ["state"] },
+ "then": { "required": ["deletion"] }
+ },
+ {
+ "if": { "properties": { "state": { "const": "deleted" } }, "required": ["state"] },
+ "then": { "properties": { "deletion": { "type": "object", "properties": { "objects_remaining": { "const": 0 }, "held_objects": { "const": 0 }, "completed_at": { "type": "string" } } } } }
+ }
+ ]
+}
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
# ADR-031: An S3-compatible surface for OpenVibe.Media
# ADR-031: An S3-compatible surface for OpenVibe.Media
-**Status:** Accepted 2026-09-26 (roadmap WS-N task 8). Implementation follows in 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
## Context and current evidence
@@ -44,3 +44,235 @@ Turn the vhost off and revoke the keys. Objects written through it stay ordinary
@@ -44,3 +44,235 @@ Turn the vhost off and revoke the keys. Objects written through it stay ordinary
- A quota overrun is refused.
- A quota overrun is refused.
- A presigned URL expires on time.
- A presigned URL expires on time.
- An object written over S3 reads through the object API with the same bytes and hash.
- An object written over S3 reads through the object API with the same bytes and hash.
+
+## 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:
+- The S3 surface is not built. Media has no S3 route.
+- Contracts has no `media.access-key` contract. No access key has been issued.
+- No client holds a `<project_key>` bucket name.
+
+### Decision
+
+**1. A zone is a named Media namespace, so there is still one object catalog.**
+- An object zone is a named namespace inside one project environment.
+- Its objects are ordinary Media objects (`med_` ids) in Media's one object table, with one lifecycle. Zone never lists, stores or copies keys or bytes.
+- The rule of the Decision above still holds, now one level down: the namespace is the unit of ownership, quota and grants, and a zone is a namespace.
+
+**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.**
+- **Zone id:** `zon_<ULID>`. It is never reused.
+- **Resource name:** `ovrn:zone:<project_id>:object-zone/<zone_id>`. It is used in grants, audit, events, usage and bills.
+ - The environment is an immutable attribute of the zone and a grant condition. It is not part of the name.
+ - Objects keep their Media resource name, `ovrn:media:<project_id>:object/<med_id>`. Media records the object's `zone_id`.
+- **Zone name:**
+ - 3 to 63 characters of `a-z`, `0-9` and single inner hyphens. A valid name is also a valid S3 bucket name.
+ - It is unique among **all** the zones of one project environment, `deleted` ones included. A name is never given to a second zone of that environment: once its zone is `deleting` or `deleted`, the name is retired, and creating a zone with it answers `409 zone.name_retired`.
+ - It is immutable. There is no rename, because the name is the bucket that clients configure.
+ - The name `default` is reserved (see 4).
+ - So within one project environment a name identifies exactly one zone id, for ever. This is what binds a signed request to one zone (see 9). S3 lets a deleted bucket's name be taken again, and a URL signed for the old bucket then reaches the new one; retiring names closes that.
+- **Media namespace:** `<environment root>.<zone_id>`, for example `app.prj_01J….sandbox.zon_01K…`. The namespace uses the id, never the name, so Media never depends on a name.
+- **One identity.** `ovrn` and `media_namespace` are derived from `id`, `project_id`, `env` and `default`. They are never set on their own.
+ - JSON Schema cannot compare two fields, so `contracts.zones.checkObjectZone(z)` adds the equality checks to the schema: the OVRN names the record's project and id, and the namespace is the record's environment root, plus `.<id>` unless the zone is the default.
+ - Zone runs the check on every record before it stores or returns it. Media runs it on every provision call and refuses a mismatch, so a projection row can never point one zone's name at another zone's namespace.
+- **Bucket name:** the zone name, resolved within the **project and environment of the access key** that signed the request.
+ - Two projects can each have a bucket named `assets`. So can one project's sandbox and its production.
+ - A key can only ever resolve its own environment's zones.
+ - Media's projection keeps `deleted` zones, so a retired name resolves to its deleted zone (`NoSuchBucket`), never to another one.
+ - This replaces the global `<project_key>` and `<project_key>-sandbox` bucket names in the Decision above. Those names were never issued.
+- **Object key:**
+ - The key is the S3 key: UTF-8, 1 to 1,024 bytes. It is unique among the live objects of one zone.
+ - An object created through the native API without a key gets a derived key (see 4). This keeps "same objects, two doors" true.
+
+**4. The default zone.**
+- Every project environment has one zone named `default`. Its Media namespace is the **environment root itself**.
+- Zone creates it with the environment. Zone backfills it for existing projects.
+- Existing objects in the environment root, and in any sub-namespace that is not a zone (a legacy child namespace), belong to it. No object moves and no namespace string changes.
+- **Derived keys.** Media gives every object without a key a key derived from its namespace and its `med_` id:
+ - an object in the environment root gets `<med_id>`;
+ - an object in a legacy child namespace `<root>.<seg1>.<seg2>` gets `<seg1>/<seg2>/<med_id>`, so `ListObjectsV2` with delimiter `/` shows that namespace as a common prefix.
+ - The backfill (see Compatibility and migration) derives keys for every row, tombstoned rows included, and finishes before the S3 vhost accepts a write. No client-chosen key exists yet, and `med_` ids are unique and never reused, so derived keys cannot collide.
+ - After that, a keyless native create checks the key's uniqueness in the transaction that creates the object. If a client has already written that exact key over S3, Media takes a new `med_` id. It never overwrites.
+ - An S3 write to the default zone stores the object in the environment root with the key as given. A `/` in a key never creates or names a Media namespace.
+- **Legacy visibility.** The default zone's `access` is `per-object`, because existing objects may be `public` or `unlisted` (`media.object@1`):
+ - Existing objects keep their `visibility`, `public_url` and cached copies. Native callers may still set `public` or `unlisted` in the default zone, as today.
+ - The S3 surface never serves an object without a signature, whatever its visibility. An object written over S3 is `private`.
+ - Making a public or unlisted object private, or deleting it, purges its CDN and public-cache copies (see 10).
+ - A project that wants a private-only bucket creates a named zone. Turning a default zone private is a later, explicit migration per project, which would set each object private and purge it. It never happens silently.
+- It cannot be deleted on its own. It goes when the environment or the project is deleted.
+- A grant on the default zone covers the environment root's subtree except the `zon_` sub-namespaces. That is the only boundary rule Media adds.
+
+**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) |
+
+- Create answers by the state of the zone of the environment that has the name: `409 zone.name_taken` if it is `provisioning` or `active`, `409 zone.name_retired` if it is `deleting` or `deleted`. It answers `403 zone.quota_exceeded` past the project's `zones` quota. Zone enforces this quota, and deleted zones do not count against it.
+- Name and environment are immutable. An update that names them is refused by its schema.
+- Delete answers `409 zone.not_empty` for a zone that still holds live objects, unless the caller sends `recursive=true`. It answers `409 zone.held` while any object of the zone is under a retention hold, as `media.object.delete` refuses a held object. The default zone answers `409 zone.default`.
+- `GET /resources` lists entries of kind `zone.object-zone` (`common.resource-summary@1`).
+- Errors are `errors.problem@1`.
+- The capabilities are `planned`. `lib/openapi.js` builds `generated/openapi` from active capabilities only, so these routes enter `generated/openapi/zone.json` in the release that implements them and turns the capabilities active. Each capability already names its routes and contracts.
+
+Zone provisions each change in Media **synchronously**:
+- Zone calls Media's internal zone endpoint (service token, planned capability `media.zone.provision`, granted only to Zone).
+- Each call carries the zone's `revision`. Media applies only a higher revision.
+- A zone becomes `active`, or a quota change takes effect, only after Media acknowledges it.
+- Media keeps a projection of the zone (id, project, environment, name, namespace, quota, state, revision). It uses the projection to resolve bucket names and enforce quotas.
+- An hourly reconciliation compares Zone's list with Media's projection. A difference is an alert, and Zone's record wins.
+
+**6. Data APIs (Media).** They change only as follows:
+- **S3** at `https://s3.openvibe.media`, path style `/<zone name>/<key>`:
+ - `ListBuckets` lists the active zones of the key's environment.
+ - `HeadBucket` is added to the supported operations, because SDKs and rclone probe with it.
+ - `CreateBucket` and `DeleteBucket` stay `NotImplemented`. Zones are created and deleted only through Zone's API, so policy and quota live in one place.
+ - An unknown bucket, or a zone that is `deleting` or `deleted`, answers `NoSuchBucket`.
+- **Native object API** (`/api/v2/:app/objects`): a request that names a zone's namespace reads and writes that zone.
+- **Overwrite:** a `PutObject` to an existing key creates a new object and deletes the previous one through the deletion path in section 10. There is no versioning in v1. An overwrite of an object under a retention hold is refused with `AccessDenied`, as its deletion would be.
+
+**7. Scoped grants.**
+- The data capabilities stay Media's: `media.object.read`, `.list`, `.upload`, `.delete` and `media.object.s3`.
+- Their resource scope (ADR-034 section 3) may name:
+ - one zone, `ovrn:zone:<project_id>:object-zone/<zone_id>`;
+ - every zone of the project, `ovrn:zone:<project_id>:object-zone/*`.
+- Media maps a zone's resource name to its namespace through the projection. Environment is a grant condition, and a sandbox credential never reaches a production zone.
+- Access keys (`media.access-key@1`, still to be written) gain an optional `zones` list of zone ids.
+ - An empty list means the whole environment, as the Decision above describes.
+ - A key scoped to zones cannot list, read, sign for or write any other zone.
+- An environment-wide grant (namespace = environment root) keeps meaning "as the project's app" and covers every zone in that environment.
+
+**8. Quotas and usage attribution.**
+- **Bytes:**
+ - The project environment's byte quota (Network, ADR-014) is the ceiling.
+ - A zone may set a lower `quota_bytes`. The sum of zone quotas may exceed the environment quota, because the environment quota is still enforced.
+ - Media checks both limits in the same request that completes a write. Either refusal is `QuotaExceeded` (403), as decided above.
+ - Only live objects count. An object counts as soon as it completes and stops counting at its tombstone, even before its copies are erased.
+- **Zone count:** the project's `zones` quota is enforced by Zone.
+- **Counting:** **Media is the only counter.** Per zone and per closed hour it emits `media.usage.recorded` (`common.usage-recorded@1`, planned for Media). Every rollup names the zone twice:
+ - `resource` is the zone's resource name, `ovrn:zone:<project_id>:object-zone/<zone_id>`, as ADR-034 section 9 requires of billable usage. Billing rates by it and the console links it.
+ - `dimension` is the zone id in lowercase (`zon_01k…`), for consumers that only group by dimension.
+ - Rollups for the default zone carry the default zone's resource name too. A rollup without `resource` is never Media's.
+- The series are:
+ - stored bytes: capability `media.object.upload`, unit `byte_hours`;
+ - write and list operations: each capability, unit `requests`;
+ - read operations: `media.object.read`, unit `requests`;
+ - egress: `media.object.read`, unit `egress_bytes`.
+- **Zone's usage view:**
+ - Zone sums Media's rollups for the zone, plus a current-bytes snapshot that Media's internal zone endpoint returns.
+ - Zone never counts bytes itself.
+ - Zone's control operations are not metered (ADR-034 section 9: a project's internal traffic is free).
+
+**9. Private access and signed URLs.**
+- Named zones are `private` only in v1. The default zone is `per-object` (see 4).
+ - A named zone's object never has a `public_url`, never enters a public cache, and refuses visibility `public` or `unlisted`.
+ - Publishing a zone through the CDN (research build step 6) is a later amendment. It would add an `access` value to `zone.object-zone@1`.
+- Presigned SigV4 `GET` and `PUT` URLs are signed by the client with a zone-scoped or environment-wide key and are valid for at most 7 days.
+- Media checks the signature, the key's scope, the zone state and the quota **when the URL is used**, not when it is signed. A URL stops working as soon as any of these happens:
+ - the key is revoked;
+ - the zone becomes `deleting`;
+ - the object is deleted.
+- **A signed request is bound to one immutable zone id.** The signature covers the access key id, which fixes the project and environment, and the path, which names the bucket. A name names one zone id in its environment for ever (see 3). So every signature resolves to the zone id it was made for, and to no other:
+ - this holds whatever signing time (`X-Amz-Date`) the client chose, past or future. Time is never what binds a URL to a zone;
+ - once that zone is `deleting` or `deleted`, the URL answers `NoSuchBucket` until it expires, and no later zone can take its name;
+ - a zone-scoped key names zone ids, so it cannot reach another zone either.
+- Media also refuses with `AccessDenied` a request whose signing time is more than 15 minutes in the future, as S3 does. This limits clock skew; it is not what binds a request to a zone.
+- A presigned URL names a key, not a version. After an overwrite it serves the new object, as S3 does.
+- A caller that holds only an app token uses Media's native signed download for an `med_` id. Zone never signs and never serves bytes.
+
+**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**:
+- S3 `DeleteObject`;
+- native `DELETE` (`media.object.delete`);
+- an overwrite;
+- zone deletion;
+- environment, project or account deletion (ADR-033).
+
+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.
+ - For S3 `DeleteObject`, native `DELETE` and an overwrite, `purge_after` is the end of the namespace's retention period, as today.
+ - For zone deletion, and for environment, project or account deletion, `purge_after` is the tombstone itself. These deletions are not restorable.
+3. **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.
+4. **Purge, fastest copies first.** After `purge_after`:
+ - an object that is or was `public` or `unlisted` first has its CDN and public-cache copies purged. Only the default zone can hold one (see 4). A named zone's objects are never in the CDN. If a later public zone is added, its objects are purged here too;
+ - the R2 hot cache and the local asset-origin and cache copies are removed, then the B2 canonical copy. For B2, every file version is deleted (`b2_delete_file_version`). A hide marker is not deletion;
+ - a copy is marked removed in `media_locations` only after the provider confirms it is gone (a `HEAD` answers not found).
+ - Every copy is erased within 24 hours of `purge_after`. A sweeper retries failed removals. A location still present after the deadline raises an operator alert.
+5. **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.
+6. **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
+
+- **Contracts:** the new `zone.*` contracts, the Zone service manifest and its capabilities are additive and `planned`. No active contract changes.
+ - When Media implements this amendment, `media.object@1` gains optional `zone_id` and `key` in a minor version. Its description already allows added fields.
+ - `media.access-key@1` is written with the `zones` scope from the start.
+ - `common.usage-recorded@1` gains an optional `resource` (a resource name). It is additive: existing producers do not send it, and their rollups are unchanged.
+- **Media, expand/migrate/contract (ADR-028):**
+ - expand: a nullable `zone_id` and `key` on objects, and the zone projection table;
+ - migrate: Zone creates each environment's default zone and pushes it. Media then backfills `zone_id` and the derived `key` (see 4) for every row, before the S3 vhost accepts writes. Metadata only: no object moves, no namespace changes, and no visibility changes;
+ - contract: `zone_id` and `key` become required for developer-project tenants, with a unique index on (`zone_id`, `key`) over live objects.
+ - First-party `:app` tenants (for example `community`) are not zones and do not change.
+- **Clients:** existing native-API callers keep working unchanged. Their namespaces are the default zone, and their objects keep their visibility, public URLs and cached copies (see 4).
+ - Native `DELETE` and restore keep the contract of `media.object.delete`: a soft delete for the retention period, a restore within it, and a refusal under a hold (see 10). What is new is that the purge after the retention period is defined to erase every copy.
+ - The one new refusal is a restore whose key a live object now holds. Only an S3 write can cause it, so a caller that never uses S3 never meets it.
+
+### Rollback
+
+- Turn off Zone's control API. Media keeps serving every zone from its projection. Zone's database holds no bytes and no object list.
+- If Zone's database is lost, its zone records can be rebuilt from Media's projection (id, project, environment, name, quota, state). Zone remains the authority once it is restored.
+- Turning off the S3 vhost (the Rollback above) leaves zones reachable through the native API.
+
+### Acceptance tests (in addition to those above)
+
+- **Two doors:** one object written over S3 to zone `assets` reads through the native API with the same `med_` id, bytes and hash, and the reverse.
+ - Deleting it through either door makes both doors and a presigned URL answer "not found".
+ - Every location row is confirmed removed. The B2 copy has no remaining file version.
+- **Isolation:** two projects each create a zone named `assets`, and each key sees only its own. A sandbox key cannot resolve a production zone. A key scoped to zone A gets `AccessDenied` on zone B.
+- **Quotas:** a write over the zone's `quota_bytes` is refused. So is a write within the zone's quota that is over the environment quota.
+- **Zone deletion:**
+ - `DELETE` on a non-empty zone without `recursive` answers `409 zone.not_empty`. On a zone with a held object it answers `409 zone.held`.
+ - With `recursive`, the zone answers `NoSuchBucket` at once, every object is tombstoned with no restore window, and every copy is erased within the deadline.
+ - Creating a zone with the deleted zone's name, while it is `deleting` and after it is `deleted`, answers `409 zone.name_retired`.
+- **Holds:** a native `DELETE`, an S3 `DeleteObject` and an S3 overwrite of a held object are each refused, and the object stays readable. A hold placed on a tombstoned object keeps its canonical copy until the hold clears, while every door answers "not found".
+- **Restore:** an object deleted over S3 is restored within the retention period through the native API and reads over S3 again with the same bytes. After `purge_after`, no copy remains.
+- **Default zone:**
+ - After the backfill, existing root-namespace objects list in bucket `default` under their `med_` id, and objects of a legacy child namespace list under its `<seg>/` prefix, without any move. Every derived key is unique.
+ - A keyless native create whose derived key a client already wrote over S3 gets a new `med_` id and overwrites nothing.
+ - A grant on the default zone does not reach a `zon_` sub-namespace.
+- **Legacy visibility:** a public object in the default zone keeps its `public_url` after the migration. An unsigned S3 `GetObject` for it answers `AccessDenied`. Deleting it purges its CDN copy.
+- **No name reuse, whatever the signing time:** presign a `GET` for zone `assets` with an `X-Amz-Date` 10 minutes in the future, and another with the current time. Delete `assets`. Both URLs answer `NoSuchBucket`, inside their 7 days and after the future date has passed. Creating a new `assets` in that environment answers `409 zone.name_retired`. The same name in the other environment, or in another project, is a different bucket and accepts neither URL.
+- **Identity:** `contracts.zones.checkObjectZone` refuses a record whose OVRN or namespace names another zone or project. Media refuses such a provision call.
+- **Usage:** every Media usage rollup carries the zone's resource name as `resource` and its id as `dimension`. Their stored-byte total matches Media's live bytes for the zone.
+- **Reconciliation:** an injected mismatch between Zone and Media's projection raises the alert.
@@ -34,7 +34,7 @@ Platform-wide decisions from the OpenVibe development roadmap (section 10). Each
@@ -34,7 +34,7 @@ Platform-wide decisions from the OpenVibe development roadmap (section 10). Each
| [ADR-028](ADR-028-expand-migrate-contract.md) | Expand, migrate, contract: schema changes that survive a rollback (7-day window); destructive-statement check and rollback-with-newer-writes test | Accepted 2026-09-25; enforced in Live |
| [ADR-028](ADR-028-expand-migrate-contract.md) | Expand, migrate, contract: schema changes that survive a rollback (7-day window); destructive-statement check and rollback-with-newer-writes test | Accepted 2026-09-25; enforced in Live |
| [ADR-029](ADR-029-account-merge.md) | Merging two accounts: survivor keeps the subject, the other becomes an alias, services follow `network.subject.merged` | Accepted 2026-09-26; implementation pending |
| [ADR-029](ADR-029-account-merge.md) | Merging two accounts: survivor keeps the subject, the other becomes an alias, services follow `network.subject.merged` | Accepted 2026-09-26; implementation pending |
| [ADR-030](ADR-030-follow-graph.md) | Follow graph: Network owns follows by subject, Live keeps a projection; expand/migrate/contract with reconciliation | Accepted 2026-09-26; migration pending |
| [ADR-030](ADR-030-follow-graph.md) | Follow graph: Network owns follows by subject, Live keeps a projection; expand/migrate/contract with reconciliation | Accepted 2026-09-26; migration pending |
-| [ADR-031](ADR-031-s3-compatible-surface.md) | S3-compatible subset for Media: bucket = project namespace, object CRUD + ListObjectsV2 + presign, per-project keys | Accepted 2026-09-26; implementation pending |
+| [ADR-031](ADR-031-s3-compatible-surface.md) | S3-compatible subset for Media: bucket = project namespace, object CRUD + ListObjectsV2 + presign, per-project keys | Accepted 2026-09-26; implementation pending; amended 2026-10-02: named object zones. Zone owns the zone record, and Media keeps the one object catalog and the only deletion path. Bucket = zone name resolved per access key; the environment root is the `default` zone; a deleted zone's name is retired |
| [ADR-032](ADR-032-containers.md) | Containers: platform services stay systemd units (one adapter in ovhost, a reserved `runtime` field); tenant code isolation belongs to Stage C | Accepted 2026-09-26 |
| [ADR-032](ADR-032-containers.md) | Containers: platform services stay systemd units (one adapter in ovhost, a reserved `runtime` field); tenant code isolation belongs to Stage C | Accepted 2026-09-26 |
| [ADR-033](ADR-033-account-export-and-deletion.md) | Account export and deletion: services push their export parts to Network; deletion after a 30-day grace, each service erases its rows and confirms; money rows kept pseudonymised | Accepted 2026-09-27; implementation in progress |
| [ADR-033](ADR-033-account-export-and-deletion.md) | Account export and deletion: services push their export parts to Network; deletion after a 30-day grace, each service erases its rows and confirms; money rows kept pseudonymised | Accepted 2026-09-27; implementation in progress |
| [ADR-034](ADR-034-platform-north-star.md) | Platform north star, an open affordable cloud: boundaries now (projects, `ovrn:` resource names, scoped grants, generated contracts, metering), infrastructure when measured; control/data plane and cells; regions, edge nodes, load balancing and a geo API; pulled, gated deploys; four extension levels under one manifest | Proposed 2026-09-28, for the owner's review |
| [ADR-034](ADR-034-platform-north-star.md) | Platform north star, an open affordable cloud: boundaries now (projects, `ovrn:` resource names, scoped grants, generated contracts, metering), infrastructure when measured; control/data plane and cells; regions, edge nodes, load balancing and a geo API; pulled, gated deploys; four extension levels under one manifest | Proposed 2026-09-28, for the owner's review |
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+{
+ "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4",
+ "env": "production",
+ "capability": "media.object.upload",
+ "resource": "ovrn:network:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:member/usr_01K2A9C3D5E7F9G1H3J5K7M9N1",
+ "dimension": "zon_01k2a9c3d5e7f9g1h3j5k7m9n1",
+ "unit": "byte_hours",
+ "window": "hour",
+ "window_start": "2026-10-02T09:00:00.000Z",
+ "window_end": "2026-10-02T10:00:00.000Z",
+ "quantity": 3774873600,
+ "errors": 0
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+{
+ "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4",
+ "env": "production",
+ "capability": "media.object.upload",
+ "resource": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y9:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1",
+ "dimension": "zon_01k2a9c3d5e7f9g1h3j5k7m9n1",
+ "unit": "byte_hours",
+ "window": "hour",
+ "window_start": "2026-10-02T09:00:00.000Z",
+ "window_end": "2026-10-02T10:00:00.000Z",
+ "quantity": 3774873600,
+ "errors": 0
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+{
+ "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4",
+ "env": "production",
+ "capability": "media.object.upload",
+ "resource": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1",
+ "dimension": "zon_01k2a9c3d5e7f9g1h3j5k7m9n1",
+ "unit": "byte_hours",
+ "window": "hour",
+ "window_start": "2026-10-02T09:00:00.000Z",
+ "window_end": "2026-10-02T10:00:00.000Z",
+ "quantity": 3774873600,
+ "errors": 0
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+{
+ "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4",
+ "env": "production",
+ "capability": "media.object.upload",
+ "resource": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1",
+ "dimension": "zon_01k2a9c3d5e7f9g1h3j5k7m9n1",
+ "unit": "byte_hours",
+ "window": "hour",
+ "window_start": "2026-10-02T09:00:00.000Z",
+ "window_end": "2026-10-02T10:00:00.000Z",
+ "quantity": 3774873600,
+ "errors": 0
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "env": "production", "name": "my--zone" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "env": "production", "name": "scratch", "access": "public" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "env": "production", "name": "default" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "env": "production", "name": "ab" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "env": "staging", "name": "scratch" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "env": "sandbox", "name": "scratch" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "env": "production", "name": "agent-artifacts", "description": "Run outputs", "quota_bytes": 5368709120 }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"force": "true"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"recursive": "yes"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"recursive": "true"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"limit": 0}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"limit": "999"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"limit": "201"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"limit": "0"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"limit": 500}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"env": "staging"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"name": "assets"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"env": "production", "limit": "200"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"env": "sandbox", "limit": "50", "cursor": "eyJhZnRlciI6Inpvbl8wMSJ9"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"include_deleted": "true"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"zones": [{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}]}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"zones": [{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}, {"id": "zon_01K1A9C3D5E7F9G1H3J5K7M9N0", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K1A9C3D5E7F9G1H3J5K7M9N0", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "access": "private", "state": "deleted", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K1A9C3D5E7F9G1H3J5K7M9N0", "quota_bytes": null, "revision": 5, "created_at": "2026-09-01T09:00:00Z", "updated_at": "2026-09-20T09:00:00Z", "deletion": {"requested_at": "2026-09-19T09:00:00Z", "objects_remaining": 0, "held_objects": 0, "completed_at": "2026-09-20T09:00:00Z"}}], "next_cursor": null}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"zones": [{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}, {"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N3", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}], "next_cursor": null}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"zones": [], "next_cursor": null}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"zones": [{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}, {"id": "zon_01K1A9C3D5E7F9G1H3J5K7M9N0", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K1A9C3D5E7F9G1H3J5K7M9N0", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "sandbox", "name": "agent-artifacts", "default": false, "access": "private", "state": "deleted", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.sandbox.zon_01K1A9C3D5E7F9G1H3J5K7M9N0", "quota_bytes": null, "revision": 5, "created_at": "2026-09-01T09:00:00Z", "updated_at": "2026-09-20T09:00:00Z", "deletion": {"requested_at": "2026-09-19T09:00:00Z", "objects_remaining": 0, "held_objects": 0, "completed_at": "2026-09-20T09:00:00Z"}}], "next_cursor": null}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"zones": [{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N2", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N2", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "sandbox", "name": "default", "default": true, "access": "per-object", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.sandbox", "quota_bytes": null, "revision": 1, "created_at": "2026-10-02T09:00:00Z"}, {"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}], "next_cursor": null}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"env": "production"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"access": "public"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"name": "new-name"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"quota_bytes": null, "description": null}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"quota_bytes": 10737418240}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"zone_id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "window_start": "2026-10-01T00:00:00Z", "window_end": "2026-10-02T10:00:00Z", "series": [{"capability": "media.object.upload", "unit": "byte_hours", "quantity": 25165824}, {"capability": "media.object.read", "unit": "requests", "quantity": 340}, {"capability": "media.object.read", "unit": "egress_bytes", "quantity": 8388608}]}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"zone_id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N9", "current": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "window_start": "2026-10-01T00:00:00Z", "window_end": "2026-10-02T10:00:00Z", "series": [{"capability": "media.object.upload", "unit": "byte_hours", "quantity": 25165824}, {"capability": "media.object.read", "unit": "requests", "quantity": 340}, {"capability": "media.object.read", "unit": "egress_bytes", "quantity": 8388608}]}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"zone_id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "current": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "window_start": "2026-10-01T00:00:00Z", "window_end": "2026-10-02T10:00:00Z", "series": [{"capability": "media.object.read", "unit": "gigabytes", "quantity": 1}]}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"zone_id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "current": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "window_start": "2026-10-01T00:00:00Z", "window_end": "2026-10-02T10:00:00Z", "series": [{"capability": "zone.zone.create", "unit": "requests", "quantity": 1}]}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"zone_id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "current": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "window_start": "2026-10-01T00:00:00Z", "window_end": "2026-10-02T10:00:00Z", "series": [{"capability": "media.object.upload", "unit": "byte_hours", "quantity": 25165824}, {"capability": "media.object.read", "unit": "requests", "quantity": 340}, {"capability": "media.object.read", "unit": "egress_bytes", "quantity": 8388608}]}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N2", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N2", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "sandbox", "name": "default", "default": true, "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.sandbox", "quota_bytes": null, "revision": 1, "created_at": "2026-10-02T09:00:00Z"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "scratch", "default": false, "access": "private", "state": "deleted", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "deletion": { "requested_at": "2026-10-02T11:00:00Z", "objects_remaining": 2, "held_objects": 0, "completed_at": "2026-10-02T12:00:00Z" }, "revision": 5, "created_at": "2026-10-02T09:00:00Z" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "scratch", "default": false, "access": "private", "state": "deleting", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "revision": 3, "created_at": "2026-10-02T09:00:00Z" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "default", "default": false, "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "revision": 1, "created_at": "2026-10-02T09:00:00Z" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "per-object", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y5.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N3", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y5:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N3", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": {"stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z"}, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z"}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "site-assets", "default": false, "access": "public", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "revision": 1, "created_at": "2026-10-02T09:00:00Z" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "sandbox", "name": "scratch", "default": false, "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "revision": 1, "created_at": "2026-10-02T09:00:00Z" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "Agent_Artifacts", "default": false, "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "revision": 1, "created_at": "2026-10-02T09:00:00Z" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "agent-artifacts", "default": false, "description": "Run outputs", "access": "private", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": 5368709120, "usage": { "stored_bytes": 1048576, "objects": 12, "as_of": "2026-10-02T10:00:00Z" }, "revision": 2, "created_at": "2026-10-02T09:00:00Z", "updated_at": "2026-10-02T09:30:00Z" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N2", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N2", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "sandbox", "name": "default", "default": true, "access": "per-object", "state": "active", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.sandbox", "quota_bytes": null, "revision": 1, "created_at": "2026-10-02T09:00:00Z" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "production", "name": "old-backups", "default": false, "access": "private", "state": "deleted", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "deletion": { "requested_at": "2026-10-02T11:00:00Z", "objects_remaining": 0, "held_objects": 0, "completed_at": "2026-10-02T12:10:00Z" }, "revision": 5, "created_at": "2026-10-01T09:00:00Z", "updated_at": "2026-10-02T12:10:00Z" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "id": "zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "ovrn": "ovrn:zone:prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4:object-zone/zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "project_id": "prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4", "env": "sandbox", "name": "scratch", "default": false, "access": "private", "state": "deleting", "media_namespace": "app.prj_01JZQ3V8X5N7K2M4P6R8T0W2Y4.sandbox.zon_01K2A9C3D5E7F9G1H3J5K7M9N1", "quota_bytes": null, "deletion": { "requested_at": "2026-10-02T11:00:00Z", "objects_remaining": 3, "held_objects": 1, "completed_at": null }, "revision": 4, "created_at": "2026-10-01T09:00:00Z", "updated_at": "2026-10-02T11:00:00Z" }
@@ -2924,14 +2924,14 @@
@@ -2924,14 +2924,14 @@
}
}
},
},
"common.usage-recorded": {
"common.usage-recorded": {
- "version": "1.0.0",
+ "version": "1.1.0",
"owner": "network",
"owner": "network",
"status": "active",
"status": "active",
"schema": {
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/common/usage-recorded.v1.json",
"$id": "https://openvibe.network/contracts/common/usage-recorded.v1.json",
"title": "UsageRecorded",
"title": "UsageRecorded",
- "description": "common.usage-recorded@1 (roadmap WS-N task 4, ADR-014): one rollup of a developer project's use of one capability in one environment over one closed window (an hour or a day), as the service that owns the capability counted it. The payload of every <service>.usage.recorded event; OpenVibe.Network adds them up per project and day for the project's dashboard on OpenVibe.Codes. The producer counts in the transaction that does its own accounting (a job's end, a stored event, a delivery attempt) and writes the rollup to its outbox after the window has closed: never one event per request. Totals, not deltas: a later event with the same key (source, project_id, env, capability, dimension, unit, window_start) replaces the earlier one, and a lower revision never replaces a higher one. Envelope: subject { type: project, id: <project_id> }, visibility internal, priority low, actor the producing service. Never carries a subject id, an address, a session, a request's input or content, or a file name.",
+ "description": "common.usage-recorded@1 (roadmap WS-N task 4, ADR-014): one rollup of a developer project's use of one capability in one environment over one closed window (an hour or a day), as the service that owns the capability counted it. The payload of every <service>.usage.recorded event; OpenVibe.Network adds them up per project and day for the project's dashboard on OpenVibe.Codes. The producer counts in the transaction that does its own accounting (a job's end, a stored event, a delivery attempt) and writes the rollup to its outbox after the window has closed: never one event per request. Totals, not deltas: a later event with the same key (source, project_id, env, capability, resource, dimension, unit, window_start) replaces the earlier one, and a lower revision never replaces a higher one. Envelope: subject { type: project, id: <project_id> }, visibility internal, priority low, actor the producing service. Never carries a subject id, an address, a session, a request's input or content, or a file name.",
"type": "object",
"type": "object",
"required": [
"required": [
"project_id",
"project_id",
@@ -2974,6 +2974,15 @@
@@ -2974,6 +2974,15 @@
},
},
"description": "Optional finer key inside the capability, as the producer names it: a job type (img.process) or a tool id (image-resize). Never the id of a person, an app, a request or a job."
"description": "Optional finer key inside the capability, as the producer names it: a job type (img.process) or a tool id (image-resize). Never the id of a person, an app, a request or a job."
},
},
+ "resource": {
+ "type": "string",
+ "maxLength": 200,
+ "pattern": "^ovrn:[a-z][a-z0-9-]{0,31}:prj_[0-9A-HJKMNP-TV-Z]{26}:[a-z][a-z0-9-]{0,39}/[a-z]{3}_[0-9A-HJKMNP-TV-Z]{26}$",
+ "not": {
+ "pattern": "/(usr|gst|app|mod|svc|job|evt|req)_"
+ },
+ "description": "Optional resource name (ADR-034 section 2) of the project resource the usage counts against, so Billing can rate billable usage by resource: ovrn:zone:<project_id>:object-zone/<zon_id> on Media's per-zone rollups (ADR-031 amendment 2026-10-02). Its project is project_id: a resource of another project is refused (contracts.usage.checkUsageRecorded). Added in 1.1.0. Never the resource name of a person, an app, a request or a job."
+ },
"unit": {
"unit": {
"type": "string",
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,31}$",
"pattern": "^[a-z][a-z0-9_]{0,31}$",
@@ -61121,6 +61130,580 @@
@@ -61121,6 +61130,580 @@
}
}
}
}
}
}
+ },
+ "zone.object-zone": {
+ "version": "1.0.0",
+ "owner": "zone",
+ "status": "planned",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/zone/object-zone.v1.json",
+ "title": "ObjectZone",
+ "description": "zone.object-zone@1 (PLANNED; ADR-031 amendment 2026-10-02): one named object zone, as OpenVibe.Zone's control API returns it (GET /api/v1/zones/{zone_id}, the list under /api/v1/projects/{project_id}/zones, and the 201 of a create). Zone owns this record: its name, environment, state, access policy and per-zone quota setting. Media owns everything inside it: the objects (ordinary med_ objects in the namespace media_namespace), their keys, bytes and replicas, quota enforcement, usage counting and the one deletion path. Zone never lists keys or holds bytes. The S3 bucket name is `name`, resolved within the project and environment of the access key that signs the request, at https://s3.openvibe.media/<name>/<key>. `ovrn` is ovrn:zone:<project_id>:object-zone/<id>, with the same project_id and id as this record. The id is never reused, and neither is a name: within one project environment a name belongs to one zone for ever, deleted or not, so a signed request names exactly one zone id. Name and env are immutable. Every environment has exactly one `default` zone, whose media_namespace is the environment root. Zone creates it with the environment, and it cannot be deleted on its own. Its access is per-object, and every other zone's is private. JSON Schema cannot compare fields: contracts.zones.checkObjectZone also requires that ovrn names this project_id and id, and that media_namespace is the environment root of project_id and env, followed by .<id> unless the zone is the default. More fields may be added.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "id",
+ "ovrn",
+ "project_id",
+ "env",
+ "name",
+ "default",
+ "access",
+ "state",
+ "media_namespace",
+ "quota_bytes",
+ "revision",
+ "created_at"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^zon_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "ovrn": {
+ "type": "string",
+ "pattern": "^ovrn:zone:prj_[0-9A-HJKMNP-TV-Z]{26}:object-zone/zon_[0-9A-HJKMNP-TV-Z]{26}$",
+ "description": "The resource name used in grants, audit, events, usage records and bills (ADR-034 section 2)."
+ },
+ "project_id": {
+ "type": "string",
+ "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "env": {
+ "enum": [
+ "sandbox",
+ "production"
+ ],
+ "description": "Fixed at creation. A sandbox key never resolves a production zone."
+ },
+ "name": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
+ "description": "The S3 bucket name. It is unique among all the zones of one project environment, deleted ones included: a deleted zone's name is retired, never given to another zone. It is valid as an S3 bucket name."
+ },
+ "default": {
+ "type": "boolean",
+ "description": "The environment's default zone, named `default`. Its media_namespace is the environment root."
+ },
+ "description": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 200
+ },
+ "access": {
+ "enum": [
+ "private",
+ "per-object"
+ ],
+ "description": "private: every named zone in v1. Its objects never have a public_url and never enter a public cache. per-object: only the default zone, whose existing objects keep their own visibility (public, unlisted or private), public URLs and cached copies, as media.object@1 allows. The S3 surface never serves an object without a signature, in either kind of zone. Publication of a named zone would add a value in a later minor version."
+ },
+ "state": {
+ "enum": [
+ "provisioning",
+ "active",
+ "deleting",
+ "deleted"
+ ],
+ "description": "provisioning: Media has not yet acknowledged the zone. active: it serves requests. deleting: requests answer NoSuchBucket while Media tombstones the objects and erases every copy. deleted: no object or copy remains. The record is kept, and its name stays retired."
+ },
+ "media_namespace": {
+ "type": "string",
+ "pattern": "^app\\.prj_[0-9A-HJKMNP-TV-Z]{26}(\\.sandbox)?(\\.zon_[0-9A-HJKMNP-TV-Z]{26})?$",
+ "description": "Where Media keeps the zone's objects. It is the environment root for the default zone, and <root>.<id> for every other zone."
+ },
+ "quota_bytes": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 1,
+ "description": "An optional byte ceiling for this zone. Media enforces it together with the project environment's byte quota, and a write over either is refused with QuotaExceeded. null means only the environment quota applies."
+ },
+ "usage": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "stored_bytes",
+ "objects",
+ "as_of"
+ ],
+ "description": "A snapshot of the live objects, as Media reports it. Zone never counts. Billed usage comes from Media's hourly media.usage.recorded rollups, whose dimension is this id in lowercase.",
+ "properties": {
+ "stored_bytes": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "objects": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "as_of": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "deletion": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "requested_at",
+ "objects_remaining",
+ "held_objects",
+ "completed_at"
+ ],
+ "description": "Progress of a zone deletion, as Media reports it.",
+ "properties": {
+ "requested_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "objects_remaining": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Objects not yet tombstoned, plus tombstoned objects with a copy not yet confirmed erased."
+ },
+ "held_objects": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Objects whose erasure waits for a retention hold (ADR-006). They are already unreadable."
+ },
+ "completed_at": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ }
+ }
+ },
+ "revision": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Raised by every change. Zone sends it when it provisions the change in Media, and Media applies only a higher revision."
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updated_at": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "default": {
+ "const": true
+ }
+ },
+ "required": [
+ "default"
+ ]
+ },
+ "then": {
+ "properties": {
+ "name": {
+ "const": "default"
+ },
+ "access": {
+ "const": "per-object"
+ },
+ "media_namespace": {
+ "type": "string",
+ "pattern": "^app\\.prj_[0-9A-HJKMNP-TV-Z]{26}(\\.sandbox)?$"
+ }
+ }
+ },
+ "else": {
+ "properties": {
+ "name": {
+ "not": {
+ "const": "default"
+ }
+ },
+ "access": {
+ "const": "private"
+ },
+ "media_namespace": {
+ "type": "string",
+ "pattern": "\\.zon_[0-9A-HJKMNP-TV-Z]{26}$"
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "env": {
+ "const": "sandbox"
+ }
+ },
+ "required": [
+ "env"
+ ]
+ },
+ "then": {
+ "properties": {
+ "media_namespace": {
+ "type": "string",
+ "pattern": "^app\\.prj_[0-9A-HJKMNP-TV-Z]{26}\\.sandbox(\\.|$)"
+ }
+ }
+ },
+ "else": {
+ "properties": {
+ "media_namespace": {
+ "type": "string",
+ "not": {
+ "pattern": "\\.sandbox(\\.|$)"
+ }
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "state": {
+ "enum": [
+ "deleting",
+ "deleted"
+ ]
+ }
+ },
+ "required": [
+ "state"
+ ]
+ },
+ "then": {
+ "required": [
+ "deletion"
+ ]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "state": {
+ "const": "deleted"
+ }
+ },
+ "required": [
+ "state"
+ ]
+ },
+ "then": {
+ "properties": {
+ "deletion": {
+ "type": "object",
+ "properties": {
+ "objects_remaining": {
+ "const": 0
+ },
+ "held_objects": {
+ "const": 0
+ },
+ "completed_at": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "zone.object-zone-create-request": {
+ "version": "1.0.0",
+ "owner": "zone",
+ "status": "planned",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/zone/object-zone-create-request.v1.json",
+ "title": "ObjectZoneCreateRequest",
+ "description": "zone.object-zone-create-request@1 (PLANNED; ADR-031 amendment 2026-10-02): the body of POST /api/v1/projects/{project_id}/zones on OpenVibe.Zone, sent with an Idempotency-Key header. The answer is 201 with a zone.object-zone@1 in state provisioning or active. A name held by a zone of that environment in state provisioning or active answers 409 zone.name_taken, and one held by a zone in state deleting or deleted answers 409 zone.name_retired, because a name belongs to one zone for ever; and going over the project's zones quota answers 403 zone.quota_exceeded. `default` cannot be requested, because Zone creates that zone with the environment.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "env",
+ "name"
+ ],
+ "properties": {
+ "env": {
+ "enum": [
+ "sandbox",
+ "production"
+ ]
+ },
+ "name": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
+ "not": {
+ "const": "default"
+ }
+ },
+ "description": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 200
+ },
+ "quota_bytes": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 1
+ }
+ }
+ }
+ },
+ "zone.object-zone-update-request": {
+ "version": "1.0.0",
+ "owner": "zone",
+ "status": "planned",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/zone/object-zone-update-request.v1.json",
+ "title": "ObjectZoneUpdateRequest",
+ "description": "zone.object-zone-update-request@1 (PLANNED; ADR-031 amendment 2026-10-02): the body of PATCH /api/v1/zones/{zone_id} on OpenVibe.Zone (capability zone.zone.manage). Only quota_bytes and description change. Name, environment and access are immutable, so a body naming them is refused. The answer is 200 with the zone.object-zone@1. A quota change takes effect once Media acknowledges it, and lowering quota_bytes below the zone's stored bytes only refuses later writes. It never deletes an object.",
+ "type": "object",
+ "additionalProperties": false,
+ "minProperties": 1,
+ "properties": {
+ "description": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 200
+ },
+ "quota_bytes": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 1,
+ "description": "null removes the zone's own ceiling, so only the environment quota applies."
+ }
+ }
+ }
+ },
+ "zone.object-zone-list-query": {
+ "version": "1.0.0",
+ "owner": "zone",
+ "status": "planned",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/zone/object-zone-list-query.v1.json",
+ "title": "ObjectZoneListQuery",
+ "description": "zone.object-zone-list-query@1 (PLANNED; ADR-031 amendment 2026-10-02): the query string of GET /api/v1/projects/{project_id}/zones on OpenVibe.Zone (capability zone.zone.list). Without env it lists both environments the caller's grant reaches. A sandbox-only grant never lists production zones.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "env": {
+ "enum": [
+ "sandbox",
+ "production"
+ ]
+ },
+ "include_deleted": {
+ "enum": [
+ true,
+ false,
+ "true",
+ "false"
+ ],
+ "description": "Also list zones in state deleted. Default false."
+ },
+ "limit": {
+ "oneOf": [
+ {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 200
+ },
+ {
+ "type": "string",
+ "pattern": "^(?:[1-9][0-9]?|1[0-9]{2}|200)$"
+ }
+ ],
+ "description": "Items per page, 1 to 200. A query string carries it as a string, which must name the same range."
+ },
+ "cursor": {
+ "type": "string",
+ "maxLength": 512,
+ "description": "next_cursor of the previous page."
+ }
+ }
+ }
+ },
+ "zone.object-zone-list": {
+ "version": "1.0.0",
+ "owner": "zone",
+ "status": "planned",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/zone/object-zone-list.v1.json",
+ "title": "ObjectZoneList",
+ "description": "zone.object-zone-list@1 (PLANNED; ADR-031 amendment 2026-10-02): the answer of GET /api/v1/projects/{project_id}/zones on OpenVibe.Zone (capability zone.zone.list), filtered by zone.object-zone-list-query@1. Zones come oldest first, the environment's default zone before the others. Deleted zones are listed only with include_deleted=true. contracts.zones.checkObjectZoneList also checks each zone's identity. More fields may be added.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "zones",
+ "next_cursor"
+ ],
+ "properties": {
+ "zones": {
+ "type": "array",
+ "maxItems": 200,
+ "items": {
+ "$ref": "object-zone.v1.json"
+ }
+ },
+ "next_cursor": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 512,
+ "description": "Pass as cursor for the next page; null on the last page."
+ }
+ }
+ }
+ },
+ "zone.object-zone-delete-query": {
+ "version": "1.0.0",
+ "owner": "zone",
+ "status": "planned",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/zone/object-zone-delete-query.v1.json",
+ "title": "ObjectZoneDeleteQuery",
+ "description": "zone.object-zone-delete-query@1 (PLANNED; ADR-031 amendment 2026-10-02): the query string of DELETE /api/v1/zones/{zone_id} on OpenVibe.Zone (capability zone.zone.delete). The answer is 202 with the zone.object-zone@1 in state deleting. Without recursive=true, a zone that still holds live objects answers 409 zone.not_empty. A zone with an object under a retention hold answers 409 zone.held, as media.object.delete refuses a held object. The default zone always answers 409 zone.default. Deletion goes through Media's one deletion path: every object is tombstoned at once, with no restore window, and every copy is erased within 24 hours. A hold placed after the request delays only that object's purge. The zone's name is retired: no later zone of the environment can take it.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "recursive": {
+ "enum": [
+ true,
+ false,
+ "true",
+ "false"
+ ],
+ "description": "Delete the zone's objects too. Default false."
+ }
+ }
+ }
+ },
+ "zone.object-zone-usage": {
+ "version": "1.0.0",
+ "owner": "zone",
+ "status": "planned",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/zone/object-zone-usage.v1.json",
+ "title": "ObjectZoneUsage",
+ "description": "zone.object-zone-usage@1 (PLANNED; ADR-031 amendment 2026-10-02): the answer of GET /api/v1/zones/{zone_id}/usage on OpenVibe.Zone (capability zone.usage.read). Zone never counts. current is the live-object snapshot that Media's internal zone endpoint returns. series sums Media's hourly media.usage.recorded rollups (common.usage-recorded@1) whose dimension is this zone id in lowercase, over the closed hours of the current UTC month. Billing rates the same rollups. Zone's control operations are not metered. ovrn is the OVRN of zone_id, never of another zone (contracts.zones.checkObjectZoneUsage). More fields may be added.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "zone_id",
+ "ovrn",
+ "current",
+ "window_start",
+ "window_end",
+ "series"
+ ],
+ "properties": {
+ "zone_id": {
+ "type": "string",
+ "pattern": "^zon_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "ovrn": {
+ "type": "string",
+ "pattern": "^ovrn:zone:prj_[0-9A-HJKMNP-TV-Z]{26}:object-zone/zon_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "current": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "stored_bytes",
+ "objects",
+ "as_of"
+ ],
+ "properties": {
+ "stored_bytes": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "objects": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "as_of": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "window_start": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The start of the current UTC month."
+ },
+ "window_end": {
+ "type": "string",
+ "format": "date-time",
+ "description": "The end of the last closed hour that Media has reported."
+ },
+ "series": {
+ "type": "array",
+ "maxItems": 32,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "capability",
+ "unit",
+ "quantity"
+ ],
+ "properties": {
+ "capability": {
+ "type": "string",
+ "pattern": "^media\\.object\\.[a-z0-9_]+$"
+ },
+ "unit": {
+ "enum": [
+ "byte_hours",
+ "requests",
+ "egress_bytes"
+ ]
+ },
+ "quantity": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ }
+ }
+ }
+ }
}
}
}
}
}
}
@@ -4691,9 +4691,9 @@ export interface ModerationAction {
@@ -4691,9 +4691,9 @@ export interface ModerationAction {
details?: {};
details?: {};
}
}
-/** [email protected] (owner: network) */
+/** [email protected] (owner: network) */
/**
/**
- * common.usage-recorded@1 (roadmap WS-N task 4, ADR-014): one rollup of a developer project's use of one capability in one environment over one closed window (an hour or a day), as the service that owns the capability counted it. The payload of every <service>.usage.recorded event; OpenVibe.Network adds them up per project and day for the project's dashboard on OpenVibe.Codes. The producer counts in the transaction that does its own accounting (a job's end, a stored event, a delivery attempt) and writes the rollup to its outbox after the window has closed: never one event per request. Totals, not deltas: a later event with the same key (source, project_id, env, capability, dimension, unit, window_start) replaces the earlier one, and a lower revision never replaces a higher one. Envelope: subject { type: project, id: <project_id> }, visibility internal, priority low, actor the producing service. Never carries a subject id, an address, a session, a request's input or content, or a file name.
+ * common.usage-recorded@1 (roadmap WS-N task 4, ADR-014): one rollup of a developer project's use of one capability in one environment over one closed window (an hour or a day), as the service that owns the capability counted it. The payload of every <service>.usage.recorded event; OpenVibe.Network adds them up per project and day for the project's dashboard on OpenVibe.Codes. The producer counts in the transaction that does its own accounting (a job's end, a stored event, a delivery attempt) and writes the rollup to its outbox after the window has closed: never one event per request. Totals, not deltas: a later event with the same key (source, project_id, env, capability, resource, dimension, unit, window_start) replaces the earlier one, and a lower revision never replaces a higher one. Envelope: subject { type: project, id: <project_id> }, visibility internal, priority low, actor the producing service. Never carries a subject id, an address, a session, a request's input or content, or a file name.
*/
*/
export interface UsageRecorded {
export interface UsageRecorded {
/**
/**
@@ -4712,6 +4712,10 @@ export interface UsageRecorded {
@@ -4712,6 +4712,10 @@ export interface UsageRecorded {
* Optional finer key inside the capability, as the producer names it: a job type (img.process) or a tool id (image-resize). Never the id of a person, an app, a request or a job.
* Optional finer key inside the capability, as the producer names it: a job type (img.process) or a tool id (image-resize). Never the id of a person, an app, a request or a job.
*/
*/
dimension?: string;
dimension?: string;
+ /**
+ * Optional resource name (ADR-034 section 2) of the project resource the usage counts against, so Billing can rate billable usage by resource: ovrn:zone:<project_id>:object-zone/<zon_id> on Media's per-zone rollups (ADR-031 amendment 2026-10-02). Its project is project_id: a resource of another project is refused (contracts.usage.checkUsageRecorded). Added in 1.1.0. Never the resource name of a person, an app, a request or a job.
+ */
+ resource?: string;
/**
/**
* What quantity counts (jobs, events, deliveries, requests, bytes, tokens): the same word a Network project quota uses for its unit.
* What quantity counts (jobs, events, deliveries, requests, bytes, tokens): the same word a Network project quota uses for its unit.
*/
*/
@@ -29742,6 +29746,10 @@ export interface ToolsUsageRecordedPayload {
@@ -29742,6 +29746,10 @@ export interface ToolsUsageRecordedPayload {
* Optional finer key inside the capability, as the producer names it: a job type (img.process) or a tool id (image-resize). Never the id of a person, an app, a request or a job.
* Optional finer key inside the capability, as the producer names it: a job type (img.process) or a tool id (image-resize). Never the id of a person, an app, a request or a job.
*/
*/
dimension?: string;
dimension?: string;
+ /**
+ * Optional resource name (ADR-034 section 2) of the project resource the usage counts against, so Billing can rate billable usage by resource: ovrn:zone:<project_id>:object-zone/<zon_id> on Media's per-zone rollups (ADR-031 amendment 2026-10-02). Its project is project_id: a resource of another project is refused (contracts.usage.checkUsageRecorded). Added in 1.1.0. Never the resource name of a person, an app, a request or a job.
+ */
+ resource?: string;
/**
/**
* What quantity counts (jobs, events, deliveries, requests, bytes, tokens): the same word a Network project quota uses for its unit.
* What quantity counts (jobs, events, deliveries, requests, bytes, tokens): the same word a Network project quota uses for its unit.
*/
*/
@@ -30706,6 +30714,10 @@ export interface EventsUsageRecordedPayload {
@@ -30706,6 +30714,10 @@ export interface EventsUsageRecordedPayload {
* Optional finer key inside the capability, as the producer names it: a job type (img.process) or a tool id (image-resize). Never the id of a person, an app, a request or a job.
* Optional finer key inside the capability, as the producer names it: a job type (img.process) or a tool id (image-resize). Never the id of a person, an app, a request or a job.
*/
*/
dimension?: string;
dimension?: string;
+ /**
+ * Optional resource name (ADR-034 section 2) of the project resource the usage counts against, so Billing can rate billable usage by resource: ovrn:zone:<project_id>:object-zone/<zon_id> on Media's per-zone rollups (ADR-031 amendment 2026-10-02). Its project is project_id: a resource of another project is refused (contracts.usage.checkUsageRecorded). Added in 1.1.0. Never the resource name of a person, an app, a request or a job.
+ */
+ resource?: string;
/**
/**
* What quantity counts (jobs, events, deliveries, requests, bytes, tokens): the same word a Network project quota uses for its unit.
* What quantity counts (jobs, events, deliveries, requests, bytes, tokens): the same word a Network project quota uses for its unit.
*/
*/
@@ -51741,3 +51753,175 @@ export interface ConfirmationRequest {
@@ -51741,3 +51753,175 @@ export interface ConfirmationRequest {
decided_at?: string;
decided_at?: string;
created_at: string;
created_at: string;
}
}
+
+/** [email protected] (owner: zone) */
+/**
+ * zone.object-zone@1 (PLANNED; ADR-031 amendment 2026-10-02): one named object zone, as OpenVibe.Zone's control API returns it (GET /api/v1/zones/{zone_id}, the list under /api/v1/projects/{project_id}/zones, and the 201 of a create). Zone owns this record: its name, environment, state, access policy and per-zone quota setting. Media owns everything inside it: the objects (ordinary med_ objects in the namespace media_namespace), their keys, bytes and replicas, quota enforcement, usage counting and the one deletion path. Zone never lists keys or holds bytes. The S3 bucket name is `name`, resolved within the project and environment of the access key that signs the request, at https://s3.openvibe.media/<name>/<key>. `ovrn` is ovrn:zone:<project_id>:object-zone/<id>, with the same project_id and id as this record. The id is never reused, and neither is a name: within one project environment a name belongs to one zone for ever, deleted or not, so a signed request names exactly one zone id. Name and env are immutable. Every environment has exactly one `default` zone, whose media_namespace is the environment root. Zone creates it with the environment, and it cannot be deleted on its own. Its access is per-object, and every other zone's is private. JSON Schema cannot compare fields: contracts.zones.checkObjectZone also requires that ovrn names this project_id and id, and that media_namespace is the environment root of project_id and env, followed by .<id> unless the zone is the default. More fields may be added.
+ */
+export type ObjectZone = {
+ [k: string]: unknown | undefined;
+} & {
+ id: string;
+ /**
+ * The resource name used in grants, audit, events, usage records and bills (ADR-034 section 2).
+ */
+ ovrn: string;
+ project_id: string;
+ /**
+ * Fixed at creation. A sandbox key never resolves a production zone.
+ */
+ env: "sandbox" | "production";
+ /**
+ * The S3 bucket name. It is unique among all the zones of one project environment, deleted ones included: a deleted zone's name is retired, never given to another zone. It is valid as an S3 bucket name.
+ */
+ name: string;
+ /**
+ * The environment's default zone, named `default`. Its media_namespace is the environment root.
+ */
+ default: boolean;
+ description?: string | null;
+ /**
+ * private: every named zone in v1. Its objects never have a public_url and never enter a public cache. per-object: only the default zone, whose existing objects keep their own visibility (public, unlisted or private), public URLs and cached copies, as media.object@1 allows. The S3 surface never serves an object without a signature, in either kind of zone. Publication of a named zone would add a value in a later minor version.
+ */
+ access: "private" | "per-object";
+ /**
+ * provisioning: Media has not yet acknowledged the zone. active: it serves requests. deleting: requests answer NoSuchBucket while Media tombstones the objects and erases every copy. deleted: no object or copy remains. The record is kept, and its name stays retired.
+ */
+ state: "provisioning" | "active" | "deleting" | "deleted";
+ /**
+ * Where Media keeps the zone's objects. It is the environment root for the default zone, and <root>.<id> for every other zone.
+ */
+ media_namespace: string;
+ /**
+ * An optional byte ceiling for this zone. Media enforces it together with the project environment's byte quota, and a write over either is refused with QuotaExceeded. null means only the environment quota applies.
+ */
+ quota_bytes: number | null;
+ /**
+ * A snapshot of the live objects, as Media reports it. Zone never counts. Billed usage comes from Media's hourly media.usage.recorded rollups, whose dimension is this id in lowercase.
+ */
+ usage?: {
+ stored_bytes: number;
+ objects: number;
+ as_of: string;
+ };
+ /**
+ * Progress of a zone deletion, as Media reports it.
+ */
+ deletion?: {
+ requested_at: string;
+ /**
+ * Objects not yet tombstoned, plus tombstoned objects with a copy not yet confirmed erased.
+ */
+ objects_remaining: number;
+ /**
+ * Objects whose erasure waits for a retention hold (ADR-006). They are already unreadable.
+ */
+ held_objects: number;
+ completed_at: string | null;
+ };
+ /**
+ * Raised by every change. Zone sends it when it provisions the change in Media, and Media applies only a higher revision.
+ */
+ revision: number;
+ created_at: string;
+ updated_at?: string | null;
+};
+
+/** [email protected] (owner: zone) */
+/**
+ * zone.object-zone-create-request@1 (PLANNED; ADR-031 amendment 2026-10-02): the body of POST /api/v1/projects/{project_id}/zones on OpenVibe.Zone, sent with an Idempotency-Key header. The answer is 201 with a zone.object-zone@1 in state provisioning or active. A name held by a zone of that environment in state provisioning or active answers 409 zone.name_taken, and one held by a zone in state deleting or deleted answers 409 zone.name_retired, because a name belongs to one zone for ever; and going over the project's zones quota answers 403 zone.quota_exceeded. `default` cannot be requested, because Zone creates that zone with the environment.
+ */
+export interface ObjectZoneCreateRequest {
+ env: "sandbox" | "production";
+ name: string;
+ description?: string | null;
+ quota_bytes?: number | null;
+}
+
+/** [email protected] (owner: zone) */
+/**
+ * zone.object-zone-update-request@1 (PLANNED; ADR-031 amendment 2026-10-02): the body of PATCH /api/v1/zones/{zone_id} on OpenVibe.Zone (capability zone.zone.manage). Only quota_bytes and description change. Name, environment and access are immutable, so a body naming them is refused. The answer is 200 with the zone.object-zone@1. A quota change takes effect once Media acknowledges it, and lowering quota_bytes below the zone's stored bytes only refuses later writes. It never deletes an object.
+ */
+export interface ObjectZoneUpdateRequest {
+ description?: string | null;
+ /**
+ * null removes the zone's own ceiling, so only the environment quota applies.
+ */
+ quota_bytes?: number | null;
+}
+
+/** [email protected] (owner: zone) */
+/**
+ * zone.object-zone-list-query@1 (PLANNED; ADR-031 amendment 2026-10-02): the query string of GET /api/v1/projects/{project_id}/zones on OpenVibe.Zone (capability zone.zone.list). Without env it lists both environments the caller's grant reaches. A sandbox-only grant never lists production zones.
+ */
+export interface ObjectZoneListQuery {
+ env?: "sandbox" | "production";
+ /**
+ * Also list zones in state deleted. Default false.
+ */
+ include_deleted?: true | false | "true" | "false";
+ /**
+ * Items per page, 1 to 200. A query string carries it as a string, which must name the same range.
+ */
+ limit?: number | string;
+ /**
+ * next_cursor of the previous page.
+ */
+ cursor?: string;
+}
+
+/** [email protected] (owner: zone) */
+/**
+ * zone.object-zone-list@1 (PLANNED; ADR-031 amendment 2026-10-02): the answer of GET /api/v1/projects/{project_id}/zones on OpenVibe.Zone (capability zone.zone.list), filtered by zone.object-zone-list-query@1. Zones come oldest first, the environment's default zone before the others. Deleted zones are listed only with include_deleted=true. contracts.zones.checkObjectZoneList also checks each zone's identity. More fields may be added.
+ */
+export interface ObjectZoneList {
+ /**
+ * @maxItems 200
+ */
+ zones: ObjectZone[];
+ /**
+ * Pass as cursor for the next page; null on the last page.
+ */
+ next_cursor: string | null;
+}
+
+/** [email protected] (owner: zone) */
+/**
+ * zone.object-zone-delete-query@1 (PLANNED; ADR-031 amendment 2026-10-02): the query string of DELETE /api/v1/zones/{zone_id} on OpenVibe.Zone (capability zone.zone.delete). The answer is 202 with the zone.object-zone@1 in state deleting. Without recursive=true, a zone that still holds live objects answers 409 zone.not_empty. A zone with an object under a retention hold answers 409 zone.held, as media.object.delete refuses a held object. The default zone always answers 409 zone.default. Deletion goes through Media's one deletion path: every object is tombstoned at once, with no restore window, and every copy is erased within 24 hours. A hold placed after the request delays only that object's purge. The zone's name is retired: no later zone of the environment can take it.
+ */
+export interface ObjectZoneDeleteQuery {
+ /**
+ * Delete the zone's objects too. Default false.
+ */
+ recursive?: true | false | "true" | "false";
+}
+
+/** [email protected] (owner: zone) */
+/**
+ * zone.object-zone-usage@1 (PLANNED; ADR-031 amendment 2026-10-02): the answer of GET /api/v1/zones/{zone_id}/usage on OpenVibe.Zone (capability zone.usage.read). Zone never counts. current is the live-object snapshot that Media's internal zone endpoint returns. series sums Media's hourly media.usage.recorded rollups (common.usage-recorded@1) whose dimension is this zone id in lowercase, over the closed hours of the current UTC month. Billing rates the same rollups. Zone's control operations are not metered. ovrn is the OVRN of zone_id, never of another zone (contracts.zones.checkObjectZoneUsage). More fields may be added.
+ */
+export interface ObjectZoneUsage {
+ zone_id: string;
+ ovrn: string;
+ current: {
+ stored_bytes: number;
+ objects: number;
+ as_of: string;
+ };
+ /**
+ * The start of the current UTC month.
+ */
+ window_start: string;
+ /**
+ * The end of the last closed hour that Media has reported.
+ */
+ window_end: string;
+ /**
+ * @maxItems 32
+ */
+ series: {
+ capability: string;
+ unit: "byte_hours" | "requests" | "egress_bytes";
+ quantity: number;
+ }[];
+}
@@ -25,6 +25,8 @@ module.exports = {
@@ -25,6 +25,8 @@ module.exports = {
serviceAuth: require('./lib/service-auth'),
serviceAuth: require('./lib/service-auth'),
modules: require('./lib/modules'),
modules: require('./lib/modules'),
tools: require('./lib/tools'),
tools: require('./lib/tools'),
+ zones: require('./lib/zones'),
+ usage: require('./lib/usage'),
staff: require('./lib/staff'),
staff: require('./lib/staff'),
openapi: require('./lib/openapi'),
openapi: require('./lib/openapi'),
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+'use strict';
+/**
+ * Usage rollup helpers (roadmap WS-N task 4). common.usage-recorded@1 holds every rule JSON Schema can
+ * express; this check adds the one that compares two fields, so the producer (before it writes its
+ * outbox) and OpenVibe.Network (before it adds a rollup up) hold a rollup to the same bar:
+ *
+ * contracts.usage.checkUsageRecorded(u) -> { valid, errors: [{ path, message }] }
+ * and a resource names a resource of the rollup's own project_id
+ */
+const registry = require('./registry');
+
+function checkUsageRecorded(u) {
+ const r = registry.validate('common.usage-recorded@1', u);
+ if (!r.valid) return r;
+ // ovrn:<service>:<project_id>:<type>/<id>: usage is attributed to project_id, so the resource must be one of its own.
+ const errors = [];
+ if (u.resource !== undefined && u.resource.split(':')[2] !== u.project_id) errors.push({ path: '/resource', message: `must name a resource of project ${u.project_id}` });
+ return { valid: errors.length === 0, errors };
+}
+
+module.exports = { checkUsageRecorded };
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+'use strict';
+/**
+ * Object zone helpers (ADR-031 amendment 2026-10-02). zone.object-zone@1 holds every rule JSON Schema
+ * can express; these checks add the ones that compare two fields, so Zone (before it stores or returns
+ * a record) and Media (on every provision call) hold a zone's identity to the same bar:
+ *
+ * contracts.zones.checkObjectZone(z) -> { valid, errors: [{ path, message }] }
+ * contracts.zones.checkObjectZoneList(l) -> the same for a zone.object-zone-list@1 answer, every item included,
+ * and no name held by two zones of one project environment
+ * contracts.zones.checkObjectZoneUsage(u) -> the same for a zone.object-zone-usage@1 answer: its ovrn names its zone_id
+ * contracts.zones.identity(z) -> { ovrn, media_namespace } derived from project_id, env, id and default
+ */
+const registry = require('./registry');
+
+/** The OVRN and Media namespace a zone must carry, derived from the fields that define it. */
+function identity(z) {
+ const root = `app.${z.project_id}${z.env === 'sandbox' ? '.sandbox' : ''}`;
+ return { ovrn: `ovrn:zone:${z.project_id}:object-zone/${z.id}`, media_namespace: z.default ? root : `${root}.${z.id}` };
+}
+
+/** Every rule for one zone record: the schema's, then that ovrn and media_namespace name this zone. */
+function checkObjectZone(z) {
+ const r = registry.validate('zone.object-zone@1', z);
+ if (!r.valid) return r;
+ const want = identity(z);
+ const errors = ['ovrn', 'media_namespace'].filter(k => z[k] !== want[k]).map(k => ({ path: `/${k}`, message: `must be ${want[k]}` }));
+ return { valid: errors.length === 0, errors };
+}
+
+function checkObjectZoneList(list) {
+ const r = registry.validate('zone.object-zone-list@1', list);
+ if (!r.valid) return r;
+ const errors = [];
+ list.zones.forEach((z, i) => { for (const e of checkObjectZone(z).errors) errors.push({ path: `/zones/${i}${e.path}`, message: e.message }); });
+ // A name belongs to one zone of its environment for ever, deleted or not: that is what binds a signed request to one zone id.
+ const owner = new Map();
+ list.zones.forEach((z, i) => {
+ const k = `${z.project_id}/${z.env}/${z.name}`;
+ if (owner.has(k) && owner.get(k) !== z.id) errors.push({ path: `/zones/${i}/name`, message: `is retired: it belongs to ${owner.get(k)} in this environment` });
+ else owner.set(k, z.id);
+ });
+ return { valid: errors.length === 0, errors };
+}
+
+/** Every rule for a zone's usage answer: the schema's, then that ovrn names the zone the answer is for. */
+function checkObjectZoneUsage(u) {
+ const r = registry.validate('zone.object-zone-usage@1', u);
+ if (!r.valid) return r;
+ const errors = u.ovrn.endsWith(`:object-zone/${u.zone_id}`) ? [] : [{ path: '/ovrn', message: `must name zone ${u.zone_id}` }];
+ return { valid: errors.length === 0, errors };
+}
+
+module.exports = { checkObjectZone, checkObjectZoneList, checkObjectZoneUsage, identity };
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "id": "zone.usage.read",
+ "version": "1.0",
+ "owner": "zone",
+ "status": "planned",
+ "visibility": "public",
+ "description": "PLANNED (ADR-031 amendment 2026-10-02): Read an object zone's usage: GET /api/v1/zones/:zone_id/usage. It answers Media's live-object snapshot and the month's hourly rollups. Zone never counts. It becomes active, and enters generated/openapi/zone.json, when OpenVibe.Zone implements the route.",
+ "outputSchema": "zone.object-zone-usage@1",
+ "permissions": [
+ "zone:read"
+ ],
+ "resourceConstraints": [
+ "project"
+ ],
+ "quotaClass": "zone-control",
+ "events": [],
+ "implementedBy": []
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "zone.zone.create",
+ "version": "1.0",
+ "owner": "zone",
+ "status": "planned",
+ "visibility": "public",
+ "description": "PLANNED (ADR-031 amendment 2026-10-02): Create a named object zone in one environment of a project. POST /api/v1/projects/:project_id/zones with an Idempotency-Key header answers 201 with the zone. A name held by a provisioning or active zone of that environment answers 409 zone.name_taken, a name held by a deleting or deleted one answers 409 zone.name_retired, and going over the project's zones quota answers 403 zone.quota_exceeded. Zone provisions the zone in Media before it becomes active. It becomes active, and enters generated/openapi/zone.json, when OpenVibe.Zone implements the route.",
+ "inputSchema": "zone.object-zone-create-request@1",
+ "outputSchema": "zone.object-zone@1",
+ "permissions": [
+ "zone:write"
+ ],
+ "resourceConstraints": [
+ "project"
+ ],
+ "quotaClass": "zone-control",
+ "events": [],
+ "implementedBy": []
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+{
+ "id": "zone.zone.delete",
+ "version": "1.0",
+ "owner": "zone",
+ "status": "planned",
+ "visibility": "public",
+ "sensitive": true,
+ "description": "PLANNED (ADR-031 amendment 2026-10-02): Delete an object zone: DELETE /api/v1/zones/:zone_id answers 202 with the zone in state deleting. A zone with objects needs recursive=true (else 409 zone.not_empty), a zone with a held object answers 409 zone.held, and the default zone answers 409 zone.default. Media tombstones every object at once, with no restore window, and erases every copy within 24 hours. A hold placed after the request delays only that object's purge. The name is retired, never reused. It becomes active, and enters generated/openapi/zone.json, when OpenVibe.Zone implements the route.",
+ "inputSchema": "zone.object-zone-delete-query@1",
+ "outputSchema": "zone.object-zone@1",
+ "permissions": [
+ "zone:write"
+ ],
+ "resourceConstraints": [
+ "project"
+ ],
+ "quotaClass": "zone-control",
+ "events": [],
+ "implementedBy": []
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "zone.zone.list",
+ "version": "1.0",
+ "owner": "zone",
+ "status": "planned",
+ "visibility": "public",
+ "description": "PLANNED (ADR-031 amendment 2026-10-02): List a project's object zones: GET /api/v1/projects/:project_id/zones, filtered by env and paged. It becomes active, and enters generated/openapi/zone.json, when OpenVibe.Zone implements the route.",
+ "inputSchema": "zone.object-zone-list-query@1",
+ "outputSchema": "zone.object-zone-list@1",
+ "permissions": [
+ "zone:read"
+ ],
+ "resourceConstraints": [
+ "project"
+ ],
+ "quotaClass": "zone-control",
+ "events": [],
+ "implementedBy": []
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "zone.zone.manage",
+ "version": "1.0",
+ "owner": "zone",
+ "status": "planned",
+ "visibility": "public",
+ "description": "PLANNED (ADR-031 amendment 2026-10-02): Change an object zone's quota_bytes or description: PATCH /api/v1/zones/:zone_id. Name, environment and access are immutable. A quota change takes effect once Media acknowledges it. It becomes active, and enters generated/openapi/zone.json, when OpenVibe.Zone implements the route.",
+ "inputSchema": "zone.object-zone-update-request@1",
+ "outputSchema": "zone.object-zone@1",
+ "permissions": [
+ "zone:write"
+ ],
+ "resourceConstraints": [
+ "project"
+ ],
+ "quotaClass": "zone-control",
+ "events": [],
+ "implementedBy": []
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "id": "zone.zone.read",
+ "version": "1.0",
+ "owner": "zone",
+ "status": "planned",
+ "visibility": "public",
+ "description": "PLANNED (ADR-031 amendment 2026-10-02): Read one object zone: GET /api/v1/zones/:zone_id. It becomes active, and enters generated/openapi/zone.json, when OpenVibe.Zone implements the route.",
+ "outputSchema": "zone.object-zone@1",
+ "permissions": [
+ "zone:read"
+ ],
+ "resourceConstraints": [
+ "project"
+ ],
+ "quotaClass": "zone-control",
+ "events": [],
+ "implementedBy": []
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+{
+ "version": "0.0.0",
+ "contractRanges": {
+ "openvibe-contracts": ">=0.87.0 <1.0.0"
+ },
+ "id": "zone",
+ "name": "OpenVibe.Zone",
+ "status": "placeholder",
+ "repository": "OpenVibers/OpenVibe.Zone",
+ "domains": [
+ "openvibe.zone",
+ "api.openvibe.zone"
+ ],
+ "publicOrigin": "https://api.openvibe.zone",
+ "exposure": {
+ "state": "placeholder",
+ "publicSite": null,
+ "note": "planned (ADR-031 amendment 2026-10-02); nothing is built or deployed, and no OpenVibe service serves openvibe.zone yet"
+ },
+ "capabilities": [
+ "zone.zone.create",
+ "zone.zone.list",
+ "zone.zone.read",
+ "zone.zone.manage",
+ "zone.zone.delete",
+ "zone.usage.read"
+ ],
+ "eventsProduced": [],
+ "eventsConsumed": [],
+ "namespacesOwned": [],
+ "lifecycle": {
+ "liveness": {
+ "none": "not built yet; nothing listens on a loopback address"
+ },
+ "shutdown": {
+ "none": "not built yet; nothing runs or drains"
+ },
+ "startupRecovery": {
+ "none": "not built yet; nothing starts"
+ },
+ "rollback": {
+ "none": "not built yet; nothing is released or rolled back"
+ },
+ "contracts": {
+ "range": ">=0.87.0 <1.0.0"
+ },
+ "leases": {
+ "none": "not built yet; nothing runs"
+ }
+ },
+ "notes": "Named object zones per project environment (ADR-031 amendment 2026-10-02). Zone owns the zone record and its control API in its own PostgreSQL database (ADR-035). OpenVibe.Media owns the objects, bytes, replicas, S3 and native data APIs, quota enforcement, usage counting and the one deletion path. Planned: nothing is built."
+}
@@ -56,7 +56,11 @@ for (const d of contracts.catalog.length ? JSON.parse(fs.readFileSync(path.join(
@@ -56,7 +56,11 @@ for (const d of contracts.catalog.length ? JSON.parse(fs.readFileSync(path.join(
// ── Fixtures: every contract has examples that pass and counter-examples that fail ──
// ── Fixtures: every contract has examples that pass and counter-examples that fail ──
// A contract with a helper that holds it to more than its schema is checked with that helper, so
// A contract with a helper that holds it to more than its schema is checked with that helper, so
// its invalid fixtures can break those rules too (a tool example whose input its own schema refuses).
// its invalid fixtures can break those rules too (a tool example whose input its own schema refuses).
-const FULL_CHECK = { 'tools.tool': (v) => contracts.tools.checkDescriptor(v), 'tools.tool-list': (v) => contracts.tools.checkList(v) };
+const FULL_CHECK = {
+ 'tools.tool': (v) => contracts.tools.checkDescriptor(v), 'tools.tool-list': (v) => contracts.tools.checkList(v),
+ 'zone.object-zone': (v) => contracts.zones.checkObjectZone(v), 'zone.object-zone-list': (v) => contracts.zones.checkObjectZoneList(v),
+ 'zone.object-zone-usage': (v) => contracts.zones.checkObjectZoneUsage(v), 'common.usage-recorded': (v) => contracts.usage.checkUsageRecorded(v),
+};
for (const c of contracts.catalog) {
for (const c of contracts.catalog) {
for (const kind of ['valid', 'invalid']) {
for (const kind of ['valid', 'invalid']) {
const dir = path.join(ROOT, 'fixtures', c.id, kind);
const dir = path.join(ROOT, 'fixtures', c.id, kind);
@@ -868,6 +872,8 @@ sub(path.join(__dirname, 'loyalty-policy.test.js'));
@@ -868,6 +872,8 @@ sub(path.join(__dirname, 'loyalty-policy.test.js'));
sub(path.join(__dirname, 'presence-policy.test.js'));
sub(path.join(__dirname, 'presence-policy.test.js'));
// Every OpenVibe.Sites catalog domain has one home in Contracts (plan T11 lane D).
// Every OpenVibe.Sites catalog domain has one home in Contracts (plan T11 lane D).
sub(path.join(__dirname, 'product-catalog.test.js'));
sub(path.join(__dirname, 'product-catalog.test.js'));
+// Named object zones: identity, retired names, list limits and per-zone usage (ADR-031 amendment 2026-10-02).
+sub(path.join(__dirname, 'zones.test.js'));
// ── Generated output and compatibility gate ──────────────────────────────
// ── Generated output and compatibility gate ──────────────────────────────
sub(path.join(__dirname, 'platform-fabric.test.js'));
sub(path.join(__dirname, 'platform-fabric.test.js'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+// ADR-031 amendment 2026-10-02: a zone's identity, retired names, the list query's limit and Media's per-zone usage.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const c = require('../index.js');
+const zones = require('../lib/zones');
+const usageLib = require('../lib/usage');
+const fx = (id, kind, f) => JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'fixtures', id, kind, f), 'utf8'));
+
+const list = fx('zone.object-zone-list', 'valid', 'two-zones.json');
+const zone = list.zones[1];
+assert.ok(c.zones.checkObjectZone(zone).valid, 'a named zone passes its identity check');
+assert.deepStrictEqual(c.zones.identity(zone), { ovrn: zone.ovrn, media_namespace: zone.media_namespace }, 'ovrn and namespace derive from the record');
+assert.ok(!c.zones.checkObjectZone({ ...zone, media_namespace: `app.${zone.project_id}.zon_01K2A9C3D5E7F9G1H3J5K7M9N9` }).valid, 'a namespace naming another zone is refused');
+
+// A name belongs to one zone of its environment for ever, so a signature names one zone id.
+const reused = c.zones.checkObjectZoneList(fx('zone.object-zone-list', 'invalid', 'retired-name-reused.json'));
+assert.ok(!reused.valid && reused.errors.some((e) => /retired/.test(e.message)), 'a deleted zone\'s name never goes to a second zone');
+assert.ok(c.zones.checkObjectZoneList(fx('zone.object-zone-list', 'valid', 'same-name-other-env.json')).valid, 'the same name in the other environment is another bucket');
+assert.ok(!/may be reused|name is free/.test(c.schema('zone.object-zone@1').description + JSON.stringify(c.schema('zone.object-zone@1').properties.state)), 'no contract text frees a name');
+
+// The query string carries limit as a string: the same 1 to 200 holds.
+for (const v of [1, 200, '1', '50', '200']) assert.ok(c.validate('zone.object-zone-list-query@1', { limit: v }).valid, `limit ${JSON.stringify(v)} is accepted`);
+for (const v of [0, 201, '0', '201', '999', '050', '']) assert.ok(!c.validate('zone.object-zone-list-query@1', { limit: v }).valid, `limit ${JSON.stringify(v)} is refused`);
+
+// Media's per-zone rollup names the zone's resource name (ADR-034 section 9).
+const usage = fx('common.usage-recorded', 'valid', 'media-zone-stored-bytes.json');
+assert.ok(c.validate('common.usage-recorded@1', usage).valid, 'a usage rollup carries a resource name');
+assert.strictEqual(usage.resource, zone.ovrn, 'the rollup\'s resource is the zone\'s ovrn');
+assert.strictEqual(usage.dimension, zone.id.toLowerCase(), 'and its dimension the zone id in lowercase');
+assert.ok(usageLib.checkUsageRecorded(usage).valid, 'its resource is a resource of its own project');
+const other = fx('common.usage-recorded', 'invalid', 'resource-names-other-project.json');
+assert.ok(c.validate('common.usage-recorded@1', other).valid && !usageLib.checkUsageRecorded(other).valid, 'a resource of another project is refused by the cross-field check');
+assert.strictEqual(c.catalog.find((e) => e.id === 'common.usage-recorded').version, '1.1.0', 'the optional resource field is a minor version');
+
+// The usage answer's ovrn names its own zone_id.
+assert.ok(zones.checkObjectZoneUsage(fx('zone.object-zone-usage', 'valid', 'month-to-date.json')).valid, 'a usage answer naming its zone passes');
+const mismatched = zones.checkObjectZoneUsage(fx('zone.object-zone-usage', 'invalid', 'ovrn-names-other-zone.json'));
+assert.ok(!mismatched.valid && mismatched.errors[0].path === '/ovrn', 'a usage answer whose ovrn names another zone is refused');
+console.log('zones: all checks passed');