Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,46 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md):
`usageHistory.negativeEvents` and `usageHistory.operatingConditions[].note`,
and textile `repairHistoryUrl`. This node never reads what a field says.

Not yet done: the erasable store for the records a `held: outside` statement
names, and the route that erases one. Until they exist an operator keeps
those records elsewhere and names them.
- **The records a `held: outside` statement names have a store of their own, and
an admin credential can erase one.** A published passport is signed, frozen,
archived and copied to the back-up provider, so personal data it is not
required to carry is held in a record that can be erased instead, and a
withdrawn consent (GDPR Art. 7(3)) obliges erasure (Art. 17(1)(b)). Core's
`PersonalDataPort` is now implemented on PostgreSQL in its own table,
`odal.personal_data_record` (migration 0043). It is not a column of the
passport, and the archive of past versions, the audit chain, the snapshots, the
outboxes and the back-up copy never see it. No route reads a record.

`DELETE /vault/api/v1/personal-data/{record}` erases one and answers the
receipt: the record, the passport and field it belonged to, and when it was
erased. Erasing a record already erased answers the first receipt again, so a
caller that lost the answer asks again. A record this node never held is `404`.
It needs an admin credential, like every other route that removes something.
It does not touch the passport or the statement in it, which keeps naming the
record, so a reader learns that the record is gone rather than that it never
existed. The store lists every record a passport has, erased ones included, so a
record whose identifier was lost on the way back from `store` can still be
found through the port.

**What a tombstone keeps, and why it needs a basis.** Erasure drops the
content and keeps the row: the identifier, the passport and field, and the
instant. None of the content, but a passport identifier can relate to whoever
owns the item, and information that can be linked to a person is personal data
(GDPR Recital 26). So the tombstone is protected like the passport's
individual-item data, and the operator needs a basis for keeping it. Showing
that it erased (Art. 5(2)) is one. Nothing here deletes it, and the database
role cannot. Erasure overwrites the content in place; the database's own
write-ahead log, replicas and backups hold earlier row versions, and how long
those live is the operator's retention.

Not yet done: no route stores a record, so nothing in this node calls `store`
or lists a passport's records. An operator who keeps records in this store
writes them through the port.

`POST /dpp/validate` now runs the same personal-data check create runs, on the
draft create would store, and so refuses a field with a value and no statement
where it used to pass. Because it now builds that draft, it also refuses a
malformed `commodityCode`, which create refused and the preview passed.

- **The GS1 resolver accepts a consumer product variant (AI 22) and a
third-party extension (AI 235).** A label printed elsewhere may carry either
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,7 @@ unprotected one. Read the table in the code.
| POST | `/vault/api/v1/unsold-goods` | Bearer **(write)** | Record an ESPR Art. 24 disclosure line for unsold consumer products discarded in a financial year. Not a passport — Art. 24's subject is an operator over a year. `exemptDestruction` requires a justification (Art. 25 bans the destruction outright from 19 July 2026); any other destination refuses one |
| GET | `/vault/api/v1/unsold-goods` | Bearer (admin) | List those lines, newest first, optionally filtered by financial year |
| POST | `/vault/api/v1/credentials` | Bearer (admin) | Issue a DPP access credential signed with this node's key. **Legitimate-interest roles only** — an authority's standing is conferred by a member state, so the three authority roles are refused. No revocation list is published, so the lifetime is capped |
| DELETE | `/vault/api/v1/personal-data/{record}` | Bearer (admin) | Erase a record a passport's `personalData` statement names, keeping a tombstone, and answer the receipt. Idempotent: a retry gets the first receipt; `404` for a record this node never held. Touches neither the passport nor its statement. **No route reads a record** — they are in `odal.personal_data_record` (migration 0043) only, never in the passport, archive or back-up copy |
| POST | `/vault/api/v1/plugins` | Bearer (admin) | Install a **signed** product group plugin and hot-swap it |
| POST | `/vault/api/v1/ruleset/reload` | Bearer (admin) | Re-read the **signed** compliance-ruleset channel and hot-swap a verified bundle |
| GET | `/vault/api/v1/webhooks` | Bearer (admin) | List webhook subscriptions |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
type: object
description: |-
What is left of an erased record of personal data: which record it was, which
passport and field it related to, and when it was erased. None of the erased
content.

The tombstone is not free of personal data. A passport identifier can relate to
whoever owns the item (GDPR Recital 26), so the receipt is as sensitive as the
passport's individual-item data and is kept only as long as the operator has a
basis to keep it.
required:
- record
- passportId
- field
- erasedAt
properties:
record:
type: string
description: The erased record's identifier, as the passport's statement names it.
passportId:
type: string
format: uuid
description: The passport the record related to.
field:
type: string
description: >-
The marked field the record related to, as a dotted path inside
`productGroupData`.
example: usageHistory.negativeEvents
erasedAt:
type: string
format: date-time
description: When the record's content was erased.
95 changes: 94 additions & 1 deletion api/openapi.bundled.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@
"name": "Webhooks",
"description": "Signed outbound event delivery — subscribe, list, remove, test."
},
{
"name": "Personal Data",
"description": "Erasing the records a passport's `personalData` statements name. A record is held outside the passport so it can be erased, and no route reads one."
},
{
"name": "Plugins",
"description": "Signed product-group plugin hot-install — verify, persist, hot-swap (admin-only)."
Expand Down Expand Up @@ -215,7 +219,7 @@
"post": {
"operationId": "validateDpp",
"summary": "Dry-run a passport body without creating it",
"description": "Runs exactly the validation `POST /vault/api/v1/dpp` runs, and persists\nnothing. The same code path serves both, so a preview and the real create\ncannot disagree.\n\n**Two verdicts, because create and publish deliberately differ.** Create is\nlenient about a product group with no resolvable JSON Schema — a draft is allowed\nto be incomplete — while publish fails closed on it, since a signed\npassport must have passed a real schema check. A body can therefore be\ncreatable but not yet clear the publish-time schema gate, and that gap is\nreported rather than hidden until publish is attempted.\n\nA body that create **would reject** gets back the identical rejection create\nwould have returned, not a paraphrase of it: a `422`, or a `409` for a\n`carrierSerial` another passport already holds. A body create would accept\nreturns `200` with the two verdicts.\n\n**`productGroupDataValid` is not a publish verdict.** It reports one of publish's\npreconditions — the product group-data gates — and publish applies others this\nroute does not run:\n\n- the **registry identity** requirement (a default facility and a primary\n operator identifier), which needs operator state this route never reads;\n- the **category-mandatory content** gate, which is reachable only by\n attempting the lifecycle transition, so it cannot be previewed;\n- the **compliance** gate, which needs a `placedOnMarketDate` and a stored\n passport.\n\nA `productGroupDataValid: true` therefore means \"this body clears the schema\ngate\", never \"publish will succeed\". The field was called `publishValid`\nand was renamed because that name promised the latter.\n",
"description": "Runs exactly the validation `POST /vault/api/v1/dpp` runs, and persists\nnothing. The same code path serves both, so a preview and the real create\ncannot disagree.\n\n**Two verdicts, because create and publish deliberately differ.** Create is\nlenient about a product group with no resolvable JSON Schema — a draft is allowed\nto be incomplete — while publish fails closed on it, since a signed\npassport must have passed a real schema check. A body can therefore be\ncreatable but not yet clear the publish-time schema gate, and that gap is\nreported rather than hidden until publish is attempted.\n\nA body that create **would reject** gets back the identical rejection create\nwould have returned, not a paraphrase of it: a `422`, or a `409` for a\n`carrierSerial` another passport already holds. A body create would accept\nreturns `200` with the two verdicts.\n\nThe `422` includes the personal-data check. A field the product group's\nschema marks as able to hold personal data that has a value and no\n`personalData` statement, a statement about a field that is not marked, an\n`outside` statement naming no record, and a basis a governing act does not\nadmit are all refused here as they are on create, each naming its field. The\ncheck reads no store, so it is part of the preview. It never reads what a\nfield says.\n\n**`productGroupDataValid` is not a publish verdict.** It reports one of publish's\npreconditions — the product group-data gates — and publish applies others this\nroute does not run:\n\n- the **registry identity** requirement (a default facility and a primary\n operator identifier), which needs operator state this route never reads;\n- the **category-mandatory content** gate, which is reachable only by\n attempting the lifecycle transition, so it cannot be previewed;\n- the **compliance** gate, which needs a `placedOnMarketDate` and a stored\n passport.\n\nA `productGroupDataValid: true` therefore means \"this body clears the schema\ngate\", never \"publish will succeed\". The field was called `publishValid`\nand was renamed because that name promised the latter.\n",
"tags": [
"DPP Management"
],
Expand Down Expand Up @@ -2815,6 +2819,64 @@
}
}
},
"/vault/api/v1/personal-data/{record}": {
"delete": {
"operationId": "erasePersonalDataRecord",
"summary": "Erase a record of personal data",
"description": "Erases a record that a passport's `personalData` statement names\n(`{\"held\": \"outside\", \"record\": \"<id>\"}`), keeping a tombstone. This is how\na withdrawn consent (GDPR Art. 7(3)) is honoured: a published passport is\nsigned, frozen, archived and copied to the back-up provider, so the personal\ndata is held in a record that can be erased instead.\n\n**Erasing touches nothing else.** Not the passport, which is signed and\nfrozen. Not the statement in it, which keeps naming the record, so a reader\nfollowing the statement learns that the record is gone rather than that it\nnever existed. Not any other record.\n\n**Idempotent.** Erasing a record that is already erased returns the receipt\nit was given the first time, so a caller that lost the answer asks again. It\ntakes no `Idempotency-Key`.\n\n**The tombstone is not free of personal data.** It keeps the identifier, the\npassport and field the record belonged to, and when it was erased — none of\nthe content. A passport identifier can relate to whoever owns the item, and\ninformation that can be linked to a person is personal data (GDPR Recital\n26). The receipt is therefore as sensitive as the passport's individual-item\ndata, and the operator needs a basis for keeping the tombstone; to show that\nit erased (Art. 5(2)) is one. Nothing in this node deletes it.\n\n**No route reads a record.** A record is served by no resolver, public,\ncredentialed or authenticated view of a passport, and is never part of the\narchive of past versions or of the back-up copy. Serving one is a disclosure\nthat needs a basis of its own, which only the controller can establish for\neach purpose.\n\nAdmin-scoped, like every other route that removes something: the write scope\nproduces and moves passports, and none of that destroys anything.\n",
"tags": [
"Personal Data"
],
"security": [
{
"BearerApiKey": []
},
{
"BasicAuth": []
}
],
"parameters": [
{
"name": "record",
"in": "path",
"required": true,
"description": "The record's identifier, as the passport's statement names it (`personalData.<field>.record`).",
"schema": {
"type": "string",
"minLength": 1
}
}
],
"responses": {
"200": {
"description": "The record is erased. The same receipt on every call after the first.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonalDataErasureReceipt"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"description": "This node never held a record with that identifier, which includes a string that could not have been one. A record that was held and is erased is not a `404`: it answers `200` with its receipt.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
}
}
}
},
"/vault/api/v1/unsold-goods": {
"get": {
"operationId": "listUnsoldGoods",
Expand Down Expand Up @@ -6639,6 +6701,37 @@
}
}
},
"PersonalDataErasureReceipt": {
"type": "object",
"description": "What is left of an erased record of personal data: which record it was, which\npassport and field it related to, and when it was erased. None of the erased\ncontent.\n\nThe tombstone is not free of personal data. A passport identifier can relate to\nwhoever owns the item (GDPR Recital 26), so the receipt is as sensitive as the\npassport's individual-item data and is kept only as long as the operator has a\nbasis to keep it.",
"required": [
"record",
"passportId",
"field",
"erasedAt"
],
"properties": {
"record": {
"type": "string",
"description": "The erased record's identifier, as the passport's statement names it."
},
"passportId": {
"type": "string",
"format": "uuid",
"description": "The passport the record related to."
},
"field": {
"type": "string",
"description": "The marked field the record related to, as a dotted path inside `productGroupData`.",
"example": "usageHistory.negativeEvents"
},
"erasedAt": {
"type": "string",
"format": "date-time",
"description": "When the record's content was erased."
}
}
},
"ValidateResponse": {
"type": "object",
"description": "The dry-run verdict. Two booleans rather than one, because create and publish deliberately differ: a body can be creatable as a draft and not yet publishable, and collapsing that into a single flag would hide the gap until the caller tried to publish.",
Expand Down
Loading
Loading