Skip to content
Merged
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
26 changes: 26 additions & 0 deletions SYSTEM_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,32 @@ This contract invents no policy and duplicates no sibling. It is a *projection*,

The module is imported by no runtime or Functions index and requires only `node:util`. It reads no clock, randomness, environment, network, Firestore, or provider service; is imported by no endpoint; stores and logs nothing; and creates, publishes, or persists no event, draft, or public record. It defines no Firestore schema or Rules, runs no importer or publish worker, and wires into no endpoint or Google-Site sync. Source change, tests, merge, Firebase deployment, provider configuration, production data, website publication, and live behavior remain separate states; #399 changes no officer task and proves none of the external or live states.

### 8.6a Approved event-source admission contract — SOURCE ONLY, UNUSED

EVENTS-001B [#439](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/439) builds the second **conservative, safe-by-default** slice of parent EVENTS-001A [#121](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/121) as one unused pure contract: the **INGEST** half of the same public-event pipeline whose **PUBLISH** half is the §8.6 `projectPublicEvent` projection ([#399](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/399)). #121's flow is *Approved structured source → validate and minimize → private draft → officer review → audited publish → [§8.6] public event view*. §8.6 owns the last arrow (a private, reviewed, server-trusted record → a minimized public **view**) and is therefore a **throwing** projector. This owns the first arrow — whether **one raw, untrusted structured-source delivery** may be admitted as a **private draft** — and is therefore a **non-throwing frozen-verdict reducer** (the idiom of §8.20–§8.22): a delivery read back from a forgeable external source makes a malformed shape a *normal* adversarial event that must resolve to a reason code, never an exception a batch importer loop has to catch. The pure `classifyEventSourceAdmission(approvedSourceEvidence, deliveryEvidence)` reads the caller's owner-approved source descriptor (`{ eventSourceAdmissionSchemaVersion, sourceId, sourceKind, active }`, or a literal `null` for "no approved source") and one raw delivery whose key set is the closed public-candidate allowlist (`{ …schemaVersion, sourceId, sourceEventId, sourceRevision, eventType, title, summary, startsAt, endsAt, timezone, locationText, publicUrl, accessibilityText, protectedOfferRef }`), and returns one frozen verdict: `admit` (the delivery may become a private draft), `refused` (a policy/authorization/boundary refusal), or `denied` (malformed shape). It imports only `node:util`.

```mermaid
flowchart TD
S{"approved source descriptor"} -- "null (unapproved)" --> Rna["refused — source_not_approved<br/>(payload never parsed)"]
S -- "malformed" --> Dsrc["denied — malformed_approved_source"]
S -- "inactive" --> Ria["refused — source_inactive"]
S -- "kind not public-eligible" --> Rknp["refused — source_kind_not_public"]
S -- "approved, active, public-eligible" --> D{"read untrusted delivery"}
D -- "extra key outside allowlist" --> Ruf["refused — unexpected_field"]
D -- "malformed shape / field" --> Ddel["denied — malformed_delivery"]
D -- "exact, well-typed" --> M{"sourceId matches descriptor?"}
M -- "No" --> Rmm["refused — source_mismatch"]
M -- "Yes" --> A["admit — private draft<br/>lifecycle draft, published false<br/>injective draftId"]
```

Text alternative: a null descriptor (no approved source) is refused `source_not_approved` **without the untrusted delivery ever being parsed**; a malformed descriptor is `malformed_approved_source`; an inactive source is `source_inactive`; a well-formed but non-public source kind (member-only discount, registration/form response, historical private item) is `source_kind_not_public`. Only for an approved, active, public-eligible source is the delivery read: any own key outside the closed allowlist is refused `unexpected_field`; any malformed container or ill-typed/out-of-grammar/missing field is `denied malformed_delivery`; an exact, well-typed delivery whose `sourceId` differs from the descriptor's is refused `source_mismatch`; only a matching delivery is admitted as a private draft.

Four marquee safety properties, each encoded structurally rather than merely guarded. **Source-authorized:** admission requires an approved, active, public-eligible descriptor whose `sourceId` matches the delivery's, and the unapproved case refuses before the forgeable payload is examined — minimal trust, fail closed. **Public/protected boundary — allowlist, never denylist:** the delivery key set must be *exactly* the closed public-candidate allowlist, so a key outside it — which is exactly how an inlined discount or promotion code, a registration or guest list, a form response, a payment state, a Stripe id, waiver evidence, an emergency contact, a private location, a door/access instruction, a member contact, an internal note, an audit record, a provider token, or a source credential would arrive — makes the key set wrong and is refused `unexpected_field`, never admitted; because the guard is a closed allowlist, no protected-field *name* appears anywhere in the source, and an unforeseen protected field is refused the same. The only permitted protected linkage is the single opaque `protectedOfferRef` id (or explicit null), never a code or terms. **Never auto-publishes:** the sole success verdict stamps `lifecycle: 'draft'`, `published: false`, and the decision vocabulary carries no publish/approve/public verb, so no path — correct or buggy — can admit a delivery straight to public visibility; an admitted draft must still pass officer review and the §8.6 projection. **Injective idempotency key:** the emitted `draftId` is a collision-free join of (sourceId, sourceEventId, sourceRevision) over a reserved `|` delimiter that the opaque component grammar excludes, so a re-delivery of the same source revision yields the SAME id (the caller dedupes — one draft per source event revision) and a new revision a provably DISTINCT one.

Content-light and defense-in-depth by construction, this contract carries only opaque handles, a closed source-kind enum, and the draft disposition — never a title, summary, location, URL, timestamp, or any event content in the verdict — and a source-boundary test enforces the absence of protected/secret vocabulary from the executable source. It validates the delivery's **shape and boundary** (each public-candidate field present, string-typed, and length-bounded for ingestion sanity; each id opaque) but **deliberately defers all public-CONTENT validation** — control characters, `<`/`>` markup, the strict https URL allowlist, UTC-timestamp component ranges, IANA timezone, `endsAt >= startsAt`, the event-type vocabulary, and the tighter per-field public bounds — to the §8.6 projection every admitted draft must still clear. Re-implementing §8.6's field validators here would duplicate a sibling; a draft this admits is never public until §8.6 clears it.

This contract invents no policy and duplicates no sibling. It is the source-authorization *ingest gate* (an approved source's untrusted delivery → a private draft), distinct from §8.6's public *projection* (a trusted reviewed record → a public view), from the `commerceEventInboxDisposition` webhook inbox gate (§8.5b, Stripe event dedup — a different domain and vocabulary), from the `commerceOutboxState` delivery contract (§8.5a), and from the §8.7 operator post lifecycle and §8.22 reminder-generation reducers. It fixes no draft/review/publish workflow, audit trail, persistence, importer, minimal query index, recurrence, or source-of-record split, and invents no owner classification — those remain with #121 and its gating owner decisions [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) (field-split approval) and [#113](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/113) (source classification). The module is imported by no runtime or Functions index and requires only `node:util`. It reads no clock, randomness, environment, network, Firestore, or provider service; is imported by no endpoint; stores and logs nothing; and creates, admits, publishes, or persists no event, draft, or public record. Source change, tests, merge, Firebase deployment, provider configuration, production data, website publication, and live behavior remain separate states; #439 changes no officer task and proves none of the external or live states.

### 8.7 Approval-gated social post lifecycle and audit — SOURCE ONLY, UNUSED

INSTAGRAM-002A [#401](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/401) builds the first **conservative, safe-by-default** slice of parent INSTAGRAM-002 [#92](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/92): one unused pure contract for the *approval-gated lifecycle of a social post*, in the same non-throwing frozen-verdict idiom as the commerce outbox sibling (`commerceOutboxState`, §8.5a). It governs only *whether a lifecycle transition is allowed and whether a publish is authorized*, never a caption, media reference, or provider. The pure `classifySocialPostTransition(current, command)` reducer reads the durable record `{ socialPostSchemaVersion, lifecycleStatus, sourceKind, payloadHash, approvedHash, authorActor, approverActor }` and one exact revision-1 command `{ socialPostSchemaVersion, type, expectedLifecycle, payloadHash, actor, capability, selfApprovalAllowed }`, both drawn only from closed vocabularies and opaque tokens, and returns one frozen verdict — `applied`, `unchanged`, or `rejected` with a fixed reason code — never throwing and never echoing raw input. A companion `validateSocialPostRecord` validates the same durable record and returns a frozen canonical projection or a fixed rejection reason. It imports only `node:util`.
Expand Down
Loading