@linn-cloud/domain-erasure
v1.0.0
Published
The erasure contract for Linn's cloud services: message types, the JSON Schema validator, the channel constants, the shared address-key helper and the response sender. Published to support Linn's own consumers; intended for internal use, unsupported, and
Readme
@linn-cloud/domain-erasure
The erasure contract between Linn's account service and the stores that hold customer data. When a customer's data must be erased, the account service publishes one message on an erasure topic; every store receives it, does its work, and answers on one response queue. This package is what both sides share: the message types, a validator, the channel's timing constants, the address helper every store compares addresses with, and a sender for the answers.
The .NET twin is the NuGet package Linn.Cloud.Common.Domain.Messaging.Erasure. Both validate against
the same JSON Schema (schema/erasure-contract.schema.json in the linn-cloud-common repository) and run
the same fixture set, so a message one accepts the other accepts.
Messages
Every message carries its type in the body, uses camelCase fields and ISO-8601 UTC times ending in Z,
and sets no SNS message attribute. No field beyond those listed is allowed.
| type | Sent by | Fields |
|---|---|---|
| erasure/ERASE | account service | requestId, sendNo, expiresAt, keyKind, key |
| erasure/REFILE | account service | requestId, sendNo, expiresAt, keyKind (ADDRESS), from, to, accountId |
| erasure/CLASSIFY | account service | requestId, expiresAt, keyKind, key |
| erasure/CHECK | account service | requestId, expiresAt, keyKind, key |
| erasure/ACK | store, answering ERASE | requestId, sendNo, subscriber, outcome: DONE or NOT_APPLICABLE |
| erasure/REFILE_ACK | store, answering REFILE | requestId, sendNo, subscriber, outcome: MOVED, NONE_OWNED, NOT_MOVED or NOT_APPLICABLE |
| erasure/HOLDINGS | store, answering CHECK or CLASSIFY | requestId, subscriber, keyKind, key, answer: HOLDS_DATA, HOLDS_NONE, CANNOT_ANSWER or NOT_APPLICABLE |
keyKindisACCOUNT(the key is an account id) orADDRESS(the key is an email address, already lowercased byaddressKey: the validator rejects one containing A to Z).sendNoidentifies one broadcast of a request; an answer echoes it.subscriberis the store's own name (ErasureSubscriber), taken from its deployed configuration, never from the message it is answering.
Handling a request in a store
import {
ErasureResponseSender,
parseErasureRequest,
} from "@linn-cloud/domain-erasure";
const request = parseErasureRequest(record.body); // throws on anything invalid, expired or not- Validate first.
parseErasureRequestthrowsErasureContractError(codeNOT_JSON,INVALID_MESSAGEorNOT_A_REQUEST) on a bad body. Let it throw: SQS retries the message and then moves it to the store's dead-letter queue, so nothing is dropped silently. The error's message is its code, never the body. - Then check the expiry with the store's own clock. An expired valid message is consumed with no answer at all.
- Then dispatch on
typeandkeyKind. A pair the store does not implement is answered at once withNOT_APPLICABLE; an implemented one does its work and answers. - Answer with
ErasureResponseSender. It validates the answer against the schema, sends it, and on failure logs only an error code and a count of failed sends, never the body, a key or arequestId, then throwsErasureResponseSendErrorso the handler fails and SQS retries.
const sender = new ErasureResponseSender(sqsClient, responsesQueueUrl, log);
await sender.send({ type: "erasure/ACK", requestId: request.requestId, sendNo: 1, subscriber: "space", outcome: "DONE" });The handler reads its pass-two delay and batch timeout from the environment variables named in
ErasureHandlerEnvironment (ERASURE_PASS_TWO_DELAY_SECONDS, ERASURE_BATCH_TIMEOUT_SECONDS); each
must stay within its cap below.
What this package cannot check
A store that answers NOT_APPLICABLE is saying it holds nothing of that kind. Nothing here can verify
that claim. Each store's own capability test, built from erasureCapabilityFixtures, and its
ErasureNotApplicable metric are the safeguards.
Addresses
addressKey(raw)lowercases A to Z and leaves every other character as typed. Every store compares addresses through it (or its .NET twin), never throughtoLowerCase, because Unicode lowercasing differs between JavaScript, .NET and Postgres. A Kelvin sign (U+212A) therefore stays a Kelvin sign, andKelvin@xandkelvin@xare two different keys. Rows some stores wrote earlier hold the fully lowercased form instead, so an erasure by address in those stores also matches that form; the second match is each store's own, not part of this contract.quarantineAddress(holdId)returnsquarantine+<holdId>@erasure.invalid: the address a deleted customer's address-keyed rows are moved to while an erasure is pending..invalidis a reserved top-level domain, so it can never be a real mailbox.holdIdmust be 1 to 64 ofa-z,0-9and-, or the function throwsRangeError.isQuarantineAddress(address)says whether an address is one of those.
A store treats a quarantine address as an ordinary address in every message. It only never sends mail to it.
The channel constants
One set for every message type, declared once in schema/erasure-channel-constants.json and read by both
packages. A store may configure less than a cap, never more. Every value is a limit chosen with
generous margin, not a measurement, so every store and the sender can rely on the same bounds.
| Name | Value | Meaning, and why |
|---|---|---|
| handlerTimeoutCapSeconds (T) | 60 | The longest a store's erasure handler may run. Chosen far inside the Lambda limit. |
| visibilityTimeoutMultiplier | 6 | A store's queue visibility timeout is exactly 6 × its handler timeout, AWS's guidance for an SQS event source. |
| visibilityTimeoutCapSeconds (V) | 360 | 6 × T. Derived. |
| maxReceiveCount (N) | 5 | Receives before a message moves to the dead-letter queue. |
| passTwoDelayCapSeconds (X) | 300 | The longest delay before a store's second pass. Chosen inside SQS's 900 s delay limit. |
| batchTimeoutCapSeconds (b) | 60 | The client-side limit on one destructive call; at most T. |
| clockSkewAllowanceSeconds (S) | 60 | Allowance for clock difference between publisher and store. Chosen generously because no documented bound on a Lambda's clock was found. |
| expiryPeriodSeconds (R) | 3,300 | A request expires R after it is published. It must cover both passes' redeliveries: 2·(N − 1)·V + 2·T + X = 3,300 s at the caps. |
| roundPublishBoundSeconds (P) | 600 | A CHECK or CLASSIFY round must be published within this of its creation: above the publisher's 5-minute tick so a failed first attempt gets one retry. |
| queueRetentionSeconds | 86,400 | 24 hours, for every queue and dead-letter queue on the channel. R must stay inside it. |
| duplicateAfterCompletionWindowSeconds | 3,480 | R + S + T + b: how long after an erasure completes a late duplicate could still act. Derived. |
The package's tests fail if R falls below 2·(N − 1)·V + 2·T + X or above the retention, so a constant cannot change without the arithmetic holding.
Capability fixtures
erasureCapabilityFixtures.pairs holds one valid request for each (type, key kind) pair a store can
implement, with the response type and the outcomes a store gives when it implements the pair and when it
does not. Its expiresAt is a placeholder in 2099: restamp it to now + R before sending. It is data
only; the package ships no responder code.
