# SG/Sentinel MVP Implementation Architecture: The `sg sentinel` Surface, The Three Targets, And The Signal Spine

**version** v0.27.59
**date** 23 May 2026
**from** Architect
**to** Developer (lead) / Claude Code (Sonnet) agent, Security, @Dev
**type** Arch brief (MVP implementation architecture)
**targets codebase** `SGraph-AI__Service__Playwright` (the `sg` CLI)

---

## What This Is

The architecture for the **first SG/Sentinel MVP**, landing the v0.27.58 design series into the `sg` codebase as a buildable, testable surface. It is the companion the implementation/dev brief expands into a file-by-file plan; together they are the acceptance criteria the Sonnet agent builds against.

This brief is deliberately scoped. It covers **two use cases** (logging to S3, blocking obviously-bad traffic), across **three execution targets** (local-direct, local-docker, live AWS), built on a **tiny core**. Everything else in the design series is explicitly deferred (see *Deferred Scope*). The point of the MVP is to prove the spine works end to end on the smallest slice that delivers real value (the real-time visibility and obvious-bad blocking the project lead wants now).

It records the decisions already locked in conversation so the agent inherits them verbatim, and it leaves a short list of open questions for the implementation brief.

## The Governing Correction: L1 Decides And Signals; L2 Acts And Writes

The single most important principle, and a correction to the design series: **Layer 1 never acts and never writes. It only decides and signals. Layer 2 is the sole actor and the sole I/O owner.**

A CloudFront Function has no network and no filesystem — it physically cannot write to S3, and by design it must not enforce either. So L1's entire job is: assign a request ID, evaluate the deterministic rules, and emit a structured **signal** downstream. Blocking, deflecting, and logging are *actions*; actions belong to the layer that can perform I/O. L1 can only *recommend* a block; L2 performs it.

This collapses the architecture to one actor and removes a category error from the earlier framing (where L1 "blocked" inline):

| Concern | Owner |
|---------|-------|
| Assign request ID, capture request fields, evaluate rules, emit verdict | **Layer 1** (decide + signal only) |
| Receive signal, enforce action, write the log record | **Layer 2** (sole actor + sole I/O) |

Both use cases now flow through one path. **Logging** = L1 signals `allow` → L2 writes the log. **Blocking** = L1 signals `block` → L2 writes the log *and* enforces (returns 4xx instead of forwarding to origin). There is no special-casing: a block is simply a logged action with an enforcement side-effect.

## Placement: `sg sentinel` As A Top-Level Peer Surface

**Decision: `sg sentinel`. Not `sg edge sentinel`, not `sg aws sentinel`.**

The repo's command tree has two tiers:

- **`sg aws *`** — raw AWS resource primitives (cf, lambda, s3, logs, firehose, iam, …), one per AWS service; the building blocks.
- **Top-level product/spec surfaces** (`sg edge`, `sg elastic`, `sg firefox`, `sg vault-app`, …) — composed stacks that orchestrate the primitives.

`sg edge` is already taken: it is **SG/Edge — the central edge tier (proxy fleet + Edge Waker)**. Nesting Sentinel under it would subordinate the guard layer to the compute tier, exactly the conflation the design series warned against ("SG/Edge runs workloads; SG/Sentinel guards what sits in front of them — distinct names, distinct roles, related infrastructure"). Sentinel is a composed product surface that *consumes* the `sg aws` primitives, so it belongs **beside** `sg edge`, not inside it and not down among the raw primitives.

Relationship to the rest of the codebase:

```
sg sentinel  (product surface; this MVP)
   │ consumes as libraries
   ├── sg aws cf      → CloudFront__AWS__Client, CloudFront__Function__AWS__Client, Schema__CF__Function
   ├── sg aws lambda  → Lambda__Deployer, Lambda__AWS__Client
   ├── sg aws s3      → S3__AWS__Client            (the log sink on AWS)
   ├── sg aws logs    → Logs__AWS__Client          (function/edge log inspection)
   └── _shared        → auth (transparent-assume), Mutation__Gate, Aws__Tagger,
                        Aws__Region__Resolver, Aws__Confirm, Aws__Context__Banner
```

Code package lives as a sibling vertical at `sgraph_ai_service_playwright__cli/sentinel/`, following the exact `cli/ service/ schemas/ primitives/ enums/ collections/` shape every `aws/*` vertical uses, plus a `runtime/` tree for the deployable L1/L2/local artefacts. The CLI app mounts into `sg_compute/cli/Cli__SG.py` via `app.add_typer(_sentinel_app, name='sentinel', …)` with a short hidden alias (`sn`).

## The Signal Spine: `Schema__Sentinel__Signal`

The signal envelope is the **deploy-parity spine** — the one contract that is byte-identical across all three targets. L1 emits it; L2 consumes it; the three-target acceptance test asserts on it.

`Schema__Sentinel__Signal` (Type_Safe), conceptual shape (full schema in the implementation brief):

| Field | Purpose |
|-------|---------|
| `request_id` | Sentinel's own ID, assigned at L1 before any rule runs |
| `aws_request_id` | CloudFront-provided ID when present (empty locally) |
| `captured` | The request fields L1 saw: method, uri/path, host, source_ip (per privacy mode), user_agent, header subset, timing |
| `verdict` | `allow` \| `block` (MVP only; `flag` deferred) |
| `reason` | Human-readable cause, e.g. "path never valid" |
| `rule_id` | The rule that produced the verdict, e.g. `0012` |
| `action` | `pass` \| `drop_403` \| `deflect_404` (block actions per addendum GAP 6.1) |
| `layer` | Deciding layer (`L1` for the MVP) |
| `engine_version`, `ruleset_version` | Parity + audit |

**Transport differs per target; the schema does not.** On AWS the envelope rides as a request header (`x-sentinel-signal: <base64 json>`, small, within CF header limits). Locally it is passed in-process from the L1 harness to the L2 module. Either way L2 deserialises into the same `Schema__Sentinel__Signal` and validates it before acting.

## The Component Model

Two runtime components plus the deployer/CLI. The L1 engine (JS) is **byte-identical** across targets; the L2 actor (Python) is **logic-identical** across targets, with two swapped dependencies: the *signal transport adapter* (how the signal arrives) and the *log sink* (where the record lands).

```
LAYER 1  — JS, no I/O, never acts                LAYER 2  — Python, sole actor + sole I/O
┌──────────────────────────────┐  signal envelope ┌────────────────────────────────────┐
│ assign request_id             │ ───────────────► │ deserialise + validate signal        │
│ capture request fields        │                  │ enforce action (pass / 403 / 404)    │
│ run deterministic rules       │                  │ build log record                     │
│ emit Schema__Sentinel__Signal │                  │ write to Log__Sink                   │
└──────────────────────────────┘                  └────────────────────────────────────┘
   runs identically on:                               same module everywhere; swap only:
   • node (local-direct)                                • transport adapter (header | in-proc)
   • docker (CF-env sim)                                • Log__Sink (S3 | local FS)
   • CloudFront Function (AWS)
```

Because L2 is a *dumb actor* in the tiny core (it enforces and writes; it does **not** re-evaluate rules), there is **no cross-language rule duplication** — rules live in JS only. Parity is about the signal envelope, not about re-running rule logic in two languages. This is a deliberate simplification that falls straight out of "L1 decides, L2 acts."

## The Three Targets

### Target A — Live AWS (ephemeral test distribution)

```
viewer-request                         origin-request (cache miss)
┌──────────────────────┐  x-sentinel-  ┌──────────────────────────────────┐
│ CloudFront Function  │  signal hdr   │ Lambda@Edge (Python)               │   ┌──────────┐
│ (L1: decide+signal)  │ ────────────► │ (L2: enforce + write S3)           │──►│  origin  │
│ always returns the   │               │ block → 403/404 (origin not hit)   │   └──────────┘
│ request, never a resp│               │ allow → forward to origin          │        │
└──────────────────────┘               │ always → write log to S3 bucket    │   ┌──────────┐
                                        └──────────────────────────────────┘   │ S3 bucket│◄─ log record
                                                                                └──────────┘
```

CloudFront association rules permit a CloudFront Function on viewer-request and a Lambda@Edge on origin-request. L@E on origin-request runs **only on cache miss**, so to make "log every hit + enforce every hit" literally true for the MVP, **the ephemeral test distribution runs cache-disabled (TTL 0)**. Every request is therefore a cache miss, every request reaches L2, every request is logged and enforceable. Cache-hit logging (which needs a viewer-response path) is a known post-MVP gap — addendum GAP 4.1 — and is explicitly out of scope here. Obvious-bad paths are never legitimately cached anyway, so blocking is unaffected by this choice.

The S3 log sink reuses `S3__AWS__Client`; the deploy reuses `CloudFront__Function__AWS__Client`, `Lambda__Deployer`, and the `_shared` auth/tagging/region/mutation-gate machinery. No Kinesis/Firehose — Sentinel writes its own clean format directly to S3, which is the founding cost argument.

### Target B — Local-direct (Node)

The host harness drives the **same L1 JS engine** via `node` invoke (synthetic or replayed request in → annotated request + signal out), then runs the **same Python L2 module** in-process to enforce and write to a **local-FS log sink**. No AWS, no network, no CloudFront. L2 is the identical code that ships to Lambda@Edge; only the transport adapter (in-proc instead of header) and the sink (local FS instead of S3) differ.

### Target C — Local-docker (CF-environment simulation)

A Docker container that simulates the CloudFront execution environment: it runs the L1 JS engine under the same Node runtime constraints CloudFront imposes, fronted by a small HTTP listener, with the Python L2 actor behind it writing to a mounted local-FS sink (or in-memory S3 double). The harness sends real HTTP requests at the container and asserts on the resulting signals/log records. This is the target that catches CF-runtime-specific surprises before they reach AWS.

## The Two Use Cases, End To End

**Use case 1 — Logging (real-time visibility to S3).** L1 assigns the ID, captures the request fields (privacy mode controls source-IP capture), runs the rules, emits the signal with `verdict=allow` for benign traffic. L2 writes the structured log record to the sink. Sentinel's job ends at the sink — the existing downstream consumer reads from there (we define the schema and adapt the consumer). The record is **replayable** (enough fidelity to re-run rules against it later), per the addendum's replay-from-S3 observation.

**Use case 2 — Blocking (obvious-bad).** Same path; L1 emits `verdict=block` with a `reason`, `rule_id`, and `action` (`drop_403` / `deflect_404`). L2 enforces by returning the action's HTTP response instead of forwarding to origin, and writes the block record (with reason) to the sink. Every block has a logged, inspectable reason.

The MVP deterministic rule set (tiny core, all `deterministic-certain`, all at L1):

| Rule | Catches | Action |
|------|---------|--------|
| `0001` capture-all | every request (assembles the log fields; pure observation) | pass |
| `0003` banned-ip | source IP in the embedded list (the canonical embedded-data example) | drop_403 |
| `0007` malformed-request | structurally invalid request | drop_403 |
| `0012` path-never-valid | `/etc/passwd` and equivalents | drop_403 |
| `0014` hidden-file-probe | `/.env`, `/.git/…` | deflect_404 |
| `0018` wp-scan-on-static | `/wp-login.php`, `/wp-admin/`, `/xmlrpc.php` on a static site | deflect_404 |

Each rule is a pure function `(captured) → {verdict, reason, rule_id, action} | null`. The engine runs them in order; **first block wins**; otherwise `allow`. Rules carry a metadata stub (`id`, `layer`, `attack_tag`, `confidence`) so the schema is present, but **fractal-graph traversal and rules-as-vault are deferred** — the MVP engine is a flat ordered list.

## Package Layout (Architecture Level)

File-by-file detail is the implementation brief's job; this is the shape.

```
sgraph_ai_service_playwright__cli/sentinel/
  cli/                Cli__Sentinel.py + groups: deploy, logs, blocks, rules, local, status
  runtime/
    layer1/           JS engine + the six MVP rules + a node entrypoint  (the CF Function source)
    layer2/           Python actor: signal parse/validate, enforce, build log record
    local/            host harness: drive L1 (node | docker), play L2, fully offline
  service/            Sentinel__Deployer (composes sg aws cf/lambda/s3), Log__Sink (S3 + LocalFS),
                      Signal__Codec (encode/decode the envelope)
  schemas/            Schema__Sentinel__Signal, Schema__Sentinel__Log_Record,
                      Schema__Sentinel__Deploy__{Request,Response}, …
  primitives/ enums/ collections/    Type_Safe building blocks (Safe_Str ids, Enum verdict/action, …)
  rules/              rule metadata registry (ids, tags); rule *logic* lives in runtime/layer1
tests/unit/.../sentinel/   mirror tree, with Sentinel__Deployer__In_Memory and an in-memory Log__Sink
```

Conventions inherited from the existing `aws/*` verticals, non-negotiable for the agent: Type_Safe everywhere (no Pydantic, no Literals); one boto3 seam per client, overridden by a dict-backed `*__In_Memory` double (no mocks, no patches, no network in unit tests); reads return typed `List__Schema__*`; CLI renders Rich tables or `--json`; deploys go through the `_shared` transparent-assume + `sg:*` tagging; mutations gated by `SG_AWS__SENTINEL__ALLOW_MUTATIONS=1`.

## The CLI Surface

```
sg sentinel deploy create   --distribution <id|new> [--region]   # provision CF Fn + L@E + S3 (cache-disabled)
sg sentinel deploy destroy  <id>
sg sentinel deploy teardown <id>
sg sentinel status                                               # what's deployed, deploy-parity, sink health
sg sentinel local up        [--docker]                           # full stack offline (direct or docker)
sg sentinel local hit <method> <path> [--ip …]                   # send a synthetic request through L1→L2
sg sentinel local down
sg sentinel logs   tail | ls | trace <request-id>                # use case 1 surfaces (read the sink)
sg sentinel blocks list | why <request-id|ip>                    # use case 2 surface
sg sentinel rules  list | show <id> | test                       # tiny-core rule visibility
```

`deploy create|destroy|teardown` require the mutation gate and a `--yes`/`--dry-run` via `Aws__Confirm`. All surfaces emit the `Aws__Context__Banner` line (role/region/account) so the operator confirms the target. The CLI is built TUI-API-friendly (structured results behind the Rich rendering) so the design series' TUI can sit on top later; the **TUI itself is post-MVP** (CLI-first, per the repo convention and the tiny-core mandate).

## Deploy Parity & The Three-Target Test Matrix

Parity is the headline acceptance property. The same canonical request set —

1. a benign static `GET /index.html`
2. `GET /etc/passwd`
3. `GET /wp-login.php`
4. `GET /.env`
5. a banned-IP request
6. a malformed request

— is driven through **all three targets** (local-direct, local-docker, live ephemeral AWS) and asserted to produce **identical signals, identical log records, and identical enforcement decisions**. The assertion target is `Schema__Sentinel__Signal` plus `Schema__Sentinel__Log_Record`. Any divergence between targets is a parity bug and a P1, per the delegation brief's deploy-parity rule. This matrix *is* the definition of "the MVP works."

## Locked Decisions (Inherit Verbatim)

1. **L1 language = native CloudFront Function (JS).** The same JS engine runs three ways: local node invoke, local Docker (CF-env simulation), and live CloudFront. Parity at L1 is JS; Python owns CLI/orchestration/deploy/L2.
2. **Local = full stack, fully offline, zero AWS dependency**, both direct (node) and via Docker (CF-env simulation). The MVP must **also** deploy and run on a live ephemeral AWS CF + Lambda@Edge. "Done" spans all three targets.
3. **Tiny core** — deterministic rules + S3 logging working end-to-end; metadata schema present but graph traversal / rules-as-vault minimal.
4. **Fingerprint / fast-track deferred** — not in this MVP.
5. **We define the S3 log schema and adapt the consumer**; design it replayable per the addendum.
6. **L1 decides and signals only; L2 is the sole actor and sole I/O owner.** L1 emits `Schema__Sentinel__Signal`; L2 enforces and writes. Blocking is a logged action with an enforcement side-effect, not an L1 capability.

## Deferred Scope (Explicit)

Not in this MVP, by design: fingerprint/fast-track; anomaly scoring and opinion rules; **any rule evaluation at L2** (L2 is a dumb actor in the tiny core); Layer 3 async/LLM; fractal-graph traversal; rules-as-vault; the evidence graph; compliance-as-living-graph; threat-intel integration; multi-CDN; SSL termination; **cache-hit logging** (needs the viewer-response path); the TUI (CLI-first for MVP). The MVP leaves room for these but builds none of them.

## Open Questions For The Implementation Brief

| Question | Notes |
|----------|-------|
| Local-FS log-sink layout | Mirror the intended S3 key layout (`…/YYYY/MM/DD/HH/…ndjson`) so replay/trace works identically local vs AWS |
| `x-sentinel-signal` size budget | Keep the envelope small; confirm against CloudFront viewer-request header limits; base64(json) for now |
| Docker base for the CF-env sim | Node runtime version/constraints that match CloudFront Functions as closely as practicable |
| L@E packaging | Reuse `Lambda__Deployer`; confirm the Lambda@Edge region/replication constraints (us-east-1 authoring) |
| Ephemeral distribution lifecycle | `deploy teardown` must leave no orphans (replicated L@E deletion timing); reuse the `sg aws cf` disable→delete sequence |
| Privacy mode for source IP | MVP default (capture vs hash vs escrow); the addendum's "IP escrowed" is post-MVP — pick the simplest honest default |
| Sentinel role profile | Register a `sentinel` family least-privilege role profile (CF + Lambda + S3 put) via `AWS__Role__Profiles` |

## Acceptance Criteria

| # | Criterion | Verification |
|---|-----------|-------------|
| 1 | `sg sentinel` mounts as a top-level peer surface (alias `sn`) | `sg sentinel --help` |
| 2 | L1 JS engine assigns an ID, captures fields, runs the six rules, emits a valid `Schema__Sentinel__Signal`; never writes, never acts | Unit + parity tests |
| 3 | L2 deserialises the signal, enforces the action, writes the log record; identical logic across targets | Unit + parity tests |
| 4 | Logging works: benign traffic produces a replayable S3/local log record per request | Use case 1 |
| 5 | Blocking works: the six rules block/deflect obvious-bad with a logged reason | Use case 2 |
| 6 | Runs local-direct (node), fully offline | Target B test |
| 7 | Runs local-docker (CF-env simulation), fully offline | Target C test |
| 8 | Deploys and runs on a live ephemeral AWS CF + Lambda@Edge (cache-disabled) | Target A test |
| 9 | Three-target parity: identical signals + log records + enforcement | The parity matrix |
| 10 | `deploy create/destroy/teardown` via CLI; mutation-gated; no orphans | Lifecycle test |
| 11 | Reuses `sg aws` clients + `_shared` (auth, tagging, region, gate); Type_Safe; in-memory doubles; no mocks | Code review |
| 12 | Cost of the live path measured against the Firehose+WAF baseline | Measurement noted |

## Relationship To Previous Briefs

| Document | Relationship |
|---|---|
| `v0.27.58__planning-brief__sg-sentinel-future-research-and-path-to-mvp.md` | The MVP boundary this implements; this is the concrete landing |
| `v0.27.58__arch-brief__sg-sentinel-architecture-and-data-flows.md` | The architecture this narrows to two use cases + three targets |
| `v0.27.58__arch-brief__edge-layer-execution-model-layered-responders.md` | The layer model; corrected here so L1 only decides+signals |
| `v0.27.58__arch-brief__sg-sentinel-rules-of-the-game-behavioural-spec.md` | The behaviour the parity matrix tests against |
| `v0.27.58__addendum__sg-sentinel-prior-art-observations-gap-resolutions.md` | GAP 1.1 (log-finalisation owner = L2), GAP 4.1 (cache-hit logging deferred), GAP 6.1 (HTTP block actions), replay-from-S3 |
| `v0.27.58__arch-brief__sg-sentinel-interactivity-and-deployment-phases.md` | Local-everywhere + deploy-parity, realised as the three-target matrix |
| `SGraph-AI__Service__Playwright` `sg aws *` + `_shared` | The primitives and cross-cutting infra Sentinel composes |

---

This document is released under the Creative Commons Attribution 4.0 International licence (CC BY 4.0).
