# SG/Sentinel MVP Implementation: File-By-File Plan, Schemas, And The Parity Test

**version** v0.27.60
**date** 23 May 2026
**from** Developer (lead)
**to** Claude Code (Sonnet) agent — implementer
**type** Dev brief (implementation)
**targets codebase** `SGraph-AI__Service__Playwright` (the `sg` CLI)
**companion** `v0.27.59__arch-brief__sg-sentinel-mvp-implementation-architecture.md` (read first)

---

## What This Is

The executable plan for the SG/Sentinel MVP. The architecture brief decided *what* and *why*; this decides *which files, which fields, which order, which tests*. Build it phase by phase; each phase ends green (all unit tests pass, `sg sentinel …` runs) before the next begins. Do not skip ahead, do not widen scope. The two use cases (logging→sink, blocking obvious-bad), three targets (local-direct, local-docker, live AWS), tiny core — nothing else.

If you find yourself wanting to add a feature from the v0.27.58 series that isn't in this brief, stop: it is deferred by design.

## Non-Negotiable Conventions (Read Before Writing Any File)

These are the house rules, observed from the existing `aws/*` verticals. Violating them is a defect even if the code "works".

1. **Type_Safe everywhere.** Every schema/service/primitive subclasses `osbot_utils.type_safe.Type_Safe`. **No Pydantic, no `typing.Literal`, no `@dataclass`, no raw dicts as contracts.** Enums subclass `(str, Enum)`. Primitives subclass the `Safe_Str__*` / `Safe_Int__*` families.
2. **File header box.** Every `.py` opens with the `# ═══…` comment box: module path, one-paragraph purpose, any EXCEPTION notes (mirror `CloudFront__AWS__Client.py`).
3. **One boto3 seam per client.** AWS clients expose a single `client()` method returning the boto3 client (via `boto3_client_via_context` from `_shared/auth/Aws__Session__Factory`). Never call `boto3.client()` elsewhere. Unit tests subclass the client and override `client()` with a dict-backed `*__In_Memory` fake. **No mocks, no patches, no network in unit tests.**
4. **Reuse, don't reach around.** All AWS work goes through the existing `aws/*` clients (`CloudFront__AWS__Client`, `CloudFront__Function__AWS__Client`, `Lambda__Deployer`, `S3__AWS__Client`, `Logs__AWS__Client`). If a needed call is missing, add a method *to that client* with an EXCEPTION header — do not open a new boto3 seam in `sentinel/`.
5. **CLI.** Typer apps with `no_args_is_help=True`; every command decorated `@spec_cli_errors` (`from sg_compute.cli.base.Spec__CLI__Errors import spec_cli_errors`); output is a Rich table or `--json`; mutating commands print `Aws__Context__Banner` and use `Aws__Confirm`.
6. **Mutation gate.** Deploy mutations require `SG_AWS__SENTINEL__ALLOW_MUTATIONS=1` (use `_shared/Mutation__Gate.require_mutation_gate`).
7. **Auth/tagging/region.** Deploy commands wrap with `aws_auth_guard(family='sentinel')`; tag every created resource via `Aws__Tagger` (`surface='sentinel'`); resolve region via `Aws__Region__Resolver`.
8. **Imports** are full absolute module paths, vertically aligned (mirror existing files).
9. **Tests** live under `tests/unit/sgraph_ai_service_playwright__cli/sentinel/…` mirroring the source tree; follow the existing test-dir layout used by `aws/cf/`.

## Build Order (Six Phases, Each Independently Green)

| Phase | Delivers | Target reached |
|-------|----------|----------------|
| **0** | Package skeleton; primitives/enums/schemas; `sg sentinel` mounts (`--help` works) | — |
| **1** | L1 JS engine + 6 rules + node tail; `sg sentinel rules list/show/test`; engine unit tests | — |
| **2** | L2 actor + `Log__Sink` (InMemory+LocalFS) + `Signal__Codec` + local-direct harness; `sg sentinel local up/hit/down`, `logs`, `blocks` | **B (local-direct)** |
| **3** | Docker CF-env sim + `sg sentinel local --docker`; B↔C parity test | **C (local-docker)** |
| **4** | `S3__Log__Sink` + `Sentinel__Deployer` (+`__In_Memory`) + `sentinel` role profile + `deploy create/destroy/teardown/status` | live path code-complete |
| **5** | Live ephemeral AWS run + three-target parity matrix test | **A (AWS) + parity = done** |

Front-loads value (offline use cases by Phase 2), defers the riskiest piece (live L@E) to last — the planning brief's sequencing.

## The File Tree

```
sgraph_ai_service_playwright__cli/sentinel/
  __init__.py
  cli/
    Cli__Sentinel.py                  # `sg sentinel` root group; mounts subgroups
    Cli__Sentinel__Deploy.py          # deploy create/destroy/teardown
    Cli__Sentinel__Local.py           # local up/hit/down
    Cli__Sentinel__Logs.py            # logs tail/ls/trace
    Cli__Sentinel__Blocks.py          # blocks list/why
    Cli__Sentinel__Rules.py           # rules list/show/test
    Cli__Sentinel__Status.py          # status
  runtime/
    layer1/
      sentinel_l1.js                  # THE engine — single, dependency-free, CF-2.0-compatible file
      rules.embedded.json             # banned-ip list etc. (inlined into the bundle at deploy)
    layer2/
      Sentinel__L2__Actor.py          # shared logic: enforce + build record + write sink
      lambda_handler.py               # Lambda@Edge origin-request adapter (AWS only)
    local/
      Sentinel__Local__Harness.py     # drive L1 (node | docker), play L2, offline
      docker/Dockerfile               # CF-env simulation
      docker/server.node.js           # tiny HTTP listener that shells `node sentinel_l1.js`
  service/
    Sentinel__Deployer.py             # composes sg aws cf/lambda/s3; create/destroy/teardown
    Signal__Codec.py                  # decode the x-sentinel-signal header → Schema__Sentinel__Signal
    log_sink/
      Log__Sink.py                    # base
      S3__Log__Sink.py                # wraps S3__AWS__Client
      Local_FS__Log__Sink.py          # local dir, same key layout
      InMemory__Log__Sink.py          # tests
    Sentinel__Role__Profile.py        # self-registers the 'sentinel' family role
  schemas/      Schema__Sentinel__*.py
  primitives/   Safe_Str__Sentinel__*.py
  enums/        Enum__Sentinel__*.py
  collections/  List__Schema__Sentinel__*.py
  rules/        Sentinel__Rule__Registry.py    # metadata (ids/tags); logic lives in layer1/sentinel_l1.js
tests/unit/sgraph_ai_service_playwright__cli/sentinel/   # mirror; + In_Memory doubles; + parity matrix
```

Mount in `sg_compute/cli/Cli__SG.py`:
```python
from sgraph_ai_service_playwright__cli.sentinel.cli.Cli__Sentinel import app as _sentinel_app
app.add_typer(_sentinel_app, name='sentinel', help='SG/Sentinel — edge guard (logging + blocking).')
app.add_typer(_sentinel_app, name='sn', hidden=True)
```

## The Schemas (Exact Fields)

Define these in Phase 0. Types reference the new `Safe_Str__Sentinel__*` primitives (below) and reuse `Safe_Str__S3__Bucket` / `Safe_Str__S3__Key` from `aws/s3/primitives`.

```python
# enums/  (all subclass (str, Enum))
class Enum__Sentinel__Verdict(str, Enum):   ALLOW='allow';  BLOCK='block'
class Enum__Sentinel__Action (str, Enum):   PASS='pass';    DROP_403='drop_403';  DEFLECT_404='deflect_404'
class Enum__Sentinel__Layer  (str, Enum):   L1='L1';        L2='L2'
class Enum__Sentinel__Target (str, Enum):   LOCAL_DIRECT='local-direct'; LOCAL_DOCKER='local-docker'; AWS='aws'

# primitives/  (Safe_Str subclasses with the family's regex/length discipline; keep permissive but bounded)
Safe_Str__Sentinel__Request_Id     # e.g. 'sn-' + 24 hex
Safe_Str__Sentinel__Rule_Id        # e.g. '0012'
Safe_Str__Sentinel__Reason         # short human string
Safe_Str__Sentinel__Path           # request path
Safe_Str__Sentinel__Host
Safe_Str__Sentinel__IP
Safe_Str__Sentinel__Method
Safe_Str__Sentinel__Timestamp      # ISO8601 'YYYY-MM-DDTHH:MM:SSZ'

# schemas/Schema__Sentinel__Captured.py  — the request as L1 saw it
class Schema__Sentinel__Captured(Type_Safe):
    method        : Safe_Str__Sentinel__Method
    path          : Safe_Str__Sentinel__Path
    host          : Safe_Str__Sentinel__Host
    source_ip     : Safe_Str__Sentinel__IP
    user_agent    : Safe_Str
    querystring   : Safe_Str
    received_at   : Safe_Str__Sentinel__Timestamp
    cache_status  : Safe_Str                          # 'miss' for the MVP (cache disabled)

# schemas/Schema__Sentinel__Signal.py  — THE parity spine; JS emits this exact shape (snake_case keys)
class Schema__Sentinel__Signal(Type_Safe):
    request_id      : Safe_Str__Sentinel__Request_Id
    aws_request_id  : Safe_Str
    captured        : Schema__Sentinel__Captured
    verdict         : Enum__Sentinel__Verdict
    reason          : Safe_Str__Sentinel__Reason
    rule_id         : Safe_Str__Sentinel__Rule_Id
    action          : Enum__Sentinel__Action
    layer           : Enum__Sentinel__Layer
    engine_version  : Safe_Str
    ruleset_version : Safe_Str

# schemas/Schema__Sentinel__Enforcement.py  — L2's decision
class Schema__Sentinel__Enforcement(Type_Safe):
    pass_to_origin : bool
    http_status    : int            # 0 when passing; 403 / 404 when blocking
    body           : Safe_Str       # '' for drop

# schemas/Schema__Sentinel__Log_Record.py  — what lands in the sink (NDJSON-serialisable)
class Schema__Sentinel__Log_Record(Type_Safe):
    request_id      : Safe_Str__Sentinel__Request_Id
    received_at     : Safe_Str__Sentinel__Timestamp
    target          : Enum__Sentinel__Target
    method          : Safe_Str__Sentinel__Method
    path            : Safe_Str__Sentinel__Path
    host            : Safe_Str__Sentinel__Host
    source_ip       : Safe_Str__Sentinel__IP        # subject to privacy mode (see Phase 2)
    user_agent      : Safe_Str
    verdict         : Enum__Sentinel__Verdict
    reason          : Safe_Str__Sentinel__Reason
    rule_id         : Safe_Str__Sentinel__Rule_Id
    action          : Enum__Sentinel__Action
    layer           : Enum__Sentinel__Layer
    enforced        : bool
    http_status     : int
    engine_version  : Safe_Str
    ruleset_version : Safe_Str

# schemas/Schema__Sentinel__Rule.py  — metadata registry entry (logic lives in JS)
class Schema__Sentinel__Rule(Type_Safe):
    rule_id     : Safe_Str__Sentinel__Rule_Id
    name        : Safe_Str
    layer       : Enum__Sentinel__Layer
    attack_tag  : Safe_Str          # MITRE technique, e.g. 'T1190'
    confidence  : Safe_Str          # 'deterministic-certain'
    action      : Enum__Sentinel__Action
    description : Safe_Str

# schemas/Schema__Sentinel__Deploy__Request.py / __Response.py
class Schema__Sentinel__Deploy__Request(Type_Safe):
    distribution_id : Safe_Str__CF__Distribution_Id   # '' → create a new ephemeral test distribution
    region          : Safe_Str__AWS__Region
    log_bucket      : Safe_Str__S3__Bucket            # '' → derive a name
    comment         : Safe_Str
class Schema__Sentinel__Deploy__Response(Type_Safe):
    distribution_id : Safe_Str__CF__Distribution_Id
    cf_function_arn : Safe_Str
    lambda_edge_arn : Safe_Str__Lambda__Arn
    log_bucket      : Safe_Str__S3__Bucket
    status          : Safe_Str

# collections/
List__Schema__Sentinel__Log_Record(Type_Safe)   # items : list[Schema__Sentinel__Log_Record]
List__Schema__Sentinel__Rule(Type_Safe)
```

## Phase 1 — The L1 Engine (`runtime/layer1/sentinel_l1.js`)

**One file, dependency-free, CloudFront Functions 2.0-compatible, runnable under plain Node.** No `require`, no `module.exports`, no `Buffer`/`btoa` (CF lacks them), no top-level `process` use. Same file runs on CF (calls `handler`), under Node (guarded tail calls `evaluate`), and in Docker (the HTTP server shells `node sentinel_l1.js`).

**Signal transport — refinement of the architecture brief:** drop base64. The header value is the **raw compact JSON string** `x-sentinel-signal: {"request_id":...}` (CF Functions can `JSON.stringify` natively; JSON contains no CR/LF so it is a valid header value). Documented fallback if any header-sanitisation issue appears: emit discrete `x-sentinel-verdict / -rule / -reason / -action / -request-id` headers instead.

**The JS emits snake_case keys identical to the Python schema field names** so `Signal__Codec.decode` maps 1:1.

```javascript
// ═══════════════════════════════════════════════════════════════════════════
// SG/Sentinel Layer 1 — sentinel_l1.js
// Decide + signal ONLY. No I/O, never blocks, always returns the request.
// Runs on: CloudFront Functions (handler), Node CLI (guarded tail), Docker server.
// ═══════════════════════════════════════════════════════════════════════════
var ENGINE_VERSION  = '0.1.0';
var RULESET_VERSION = '0.1.0';
var BANNED_IPS      = [/* inlined from rules.embedded.json at deploy time */];

function evaluate(c) {                       // c = captured request object (snake_case)
  var hit =
        rule_0007_malformed(c)      ||
        rule_0003_banned_ip(c)      ||
        rule_0012_path_never_valid(c) ||
        rule_0014_hidden_file(c)    ||
        rule_0018_wp_scan(c)        ||
        { verdict:'allow', reason:'no rule matched', rule_id:'0001', action:'pass' };
  return {
    request_id: c.request_id, aws_request_id: c.aws_request_id || '',
    captured: c, verdict: hit.verdict, reason: hit.reason,
    rule_id: hit.rule_id, action: hit.action, layer: 'L1',
    engine_version: ENGINE_VERSION, ruleset_version: RULESET_VERSION
  };
}
function rule_0007_malformed(c){ if(!c.method||!c.path||c.path.charAt(0)!=='/') return {verdict:'block',reason:'malformed request',rule_id:'0007',action:'drop_403'}; }
function rule_0003_banned_ip(c){ if(BANNED_IPS.indexOf(c.source_ip)>=0) return {verdict:'block',reason:'banned ip',rule_id:'0003',action:'drop_403'}; }
function rule_0012_path_never_valid(c){ var p=c.path.toLowerCase(); if(p.indexOf('/etc/passwd')>=0||p.indexOf('..')>=0) return {verdict:'block',reason:'path never valid',rule_id:'0012',action:'drop_403'}; }
function rule_0014_hidden_file(c){ if(/^\/\.(env|git)(\/|$)/.test(c.path)) return {verdict:'block',reason:'hidden file probe',rule_id:'0014',action:'deflect_404'}; }
function rule_0018_wp_scan(c){ var p=c.path.toLowerCase(); if(p==='/wp-login.php'||p==='/xmlrpc.php'||p.indexOf('/wp-admin')===0) return {verdict:'block',reason:'wordpress scan on static site',rule_id:'0018',action:'deflect_404'}; }

function handler(event) {                    // CloudFront Functions entry (viewer-request)
  var r = event.request;
  var c = {
    request_id: 'sn-' + (event.context && event.context.requestId ? event.context.requestId : new Date().getTime().toString(16)),
    aws_request_id: (event.context && event.context.requestId) || '',
    method: r.method, path: r.uri, querystring: (r.querystring && r.querystring.value) || '',
    host: (r.headers.host && r.headers.host.value) || '',
    source_ip: (event.viewer && event.viewer.ip) || '',
    user_agent: (r.headers['user-agent'] && r.headers['user-agent'].value) || '',
    received_at: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
    cache_status: 'miss'
  };
  r.headers['x-sentinel-signal'] = { value: JSON.stringify(evaluate(c)) };
  return r;                                  // ALWAYS return the request; L1 never acts
}

// Node CLI tail — guarded so it never runs on CloudFront (no `process` there)
if (typeof process !== 'undefined' && process.argv && process.argv[2]) {
  console.log(JSON.stringify(evaluate(JSON.parse(process.argv[2]))));
}
```

`Sentinel__Rule__Registry.py` returns the six `Schema__Sentinel__Rule` rows for `sg sentinel rules list/show`. `rules test` shells `node sentinel_l1.js '<captured>'` over the canonical request set and renders the resulting signals.

**Phase 1 tests:** drive `node sentinel_l1.js` (subprocess) over each canonical request; assert the emitted signal deserialises into `Schema__Sentinel__Signal` with the expected verdict/rule_id/action. (Node availability: `@skipUnless(shutil.which('node'))`; CI image has Node.)

## Phase 2 — The L2 Actor, Sinks, Codec, Local-Direct Harness

`Signal__Codec.decode(header_value: str) -> Schema__Sentinel__Signal` (`json.loads` → construct the Type_Safe object; validates by construction). `encode` exists for tests/symmetry.

`Log__Sink` base + three implementations. **Identical key layout** across S3 and local so `trace`/replay behave the same: `<prefix>/YYYY/MM/DD/HH/<request_id>.json`, one object per record (batching deferred — note it). `read_all()` / `list()` / `get(request_id)` for the `logs`/`blocks` CLI.

```python
# Sentinel__L2__Actor — the sole actor; identical logic on AWS and locally
class Sentinel__L2__Actor(Type_Safe):
    log_sink     : Log__Sink
    privacy_mode : Safe_Str = 'hash'          # MVP default: hash source_ip (sha256[:12]); 'plain' / 'omit' also supported

    def handle(self, signal   : Schema__Sentinel__Signal,
                     target   : Enum__Sentinel__Target) -> Schema__Sentinel__Enforcement:
        enforcement = self.enforce(signal)
        record      = self.build_record(signal, target, enforcement)
        self.log_sink.write(record)           # L2 is the sole I/O owner
        return enforcement

    def enforce(self, signal) -> Schema__Sentinel__Enforcement:
        if signal.verdict == Enum__Sentinel__Verdict.BLOCK:
            if signal.action == Enum__Sentinel__Action.DEFLECT_404:
                return Schema__Sentinel__Enforcement(pass_to_origin=False, http_status=404, body='')
            return     Schema__Sentinel__Enforcement(pass_to_origin=False, http_status=403, body='')
        return         Schema__Sentinel__Enforcement(pass_to_origin=True,  http_status=0,   body='')

    def build_record(self, signal, target, enforcement) -> Schema__Sentinel__Log_Record:
        ...   # map signal.captured + verdict/reason/rule_id/action + enforced/http_status + target; apply privacy_mode to source_ip
```

`Sentinel__Local__Harness` (Phase 2, direct mode): given a request dict, `subprocess.run(['node', sentinel_l1_path, json.dumps(captured)])` → signal JSON → `Signal__Codec.decode` → `Sentinel__L2__Actor(log_sink=Local_FS__Log__Sink|InMemory).handle(signal, LOCAL_DIRECT)`. This **is** the offline full stack: the real JS L1 + the real Python L2.

CLI wires up: `sg sentinel local hit GET /etc/passwd --ip 1.2.3.4` → runs the harness, prints the signal + enforcement; `sg sentinel logs tail|ls|trace <id>` and `blocks list|why <id>` read the sink. **End of Phase 2: use cases 1 + 2 work fully offline (Target B).**

## Phase 3 — Docker (CF-Environment Simulation)

`runtime/local/docker/Dockerfile`: a Node image pinned to the runtime that matches CloudFront Functions as closely as practicable (record the chosen version in the file header); copies `sentinel_l1.js` + `server.node.js`; exposes an HTTP port. `server.node.js` is a minimal `http` listener that, per request, builds the captured object from the incoming HTTP request and **shells `node sentinel_l1.js '<captured>'`** (same file, no divergence), returning the signal. The harness in `--docker` mode POSTs canonical requests at the container, then runs the same Python L2 actor on the returned signal. **End of Phase 3: Target C; add a B↔C parity assertion.**

## Phase 4 — Live AWS Deploy Path

`Sentinel__Deployer` composes the existing clients (no new boto3 seam):

1. **S3 log bucket** via `S3__AWS__Client` (create if absent; block public access; tag `surface=sentinel`).
2. **CF Function (L1)** via `CloudFront__Function__AWS__Client`: publish `sentinel_l1.js` with `BANNED_IPS` inlined from `rules.embedded.json` at publish time (trivial string substitution — keep `sentinel_l1.js` the single source; the deployer produces the inlined variant in memory).
3. **Lambda@Edge (L2)** via `Lambda__Deployer`: package `runtime/layer2/` (the actor + `lambda_handler.py` + the S3 sink). **Lambda@Edge has no environment variables** — the deployer must **template a generated `_sentinel_config.py`** (log bucket, region, ruleset_version) into the zip before publishing. Publish a **version** (L@E requires a numbered version ARN, not `$LATEST`).
4. **Distribution** via `CloudFront__AWS__Client` + builder: create the ephemeral test distribution **cache-disabled (min/default/max TTL = 0)**; associate the CF Function on **viewer-request** and the L@E version on **origin-request**.
5. Return `Schema__Sentinel__Deploy__Response`.

`destroy`/`teardown`: disable→delete distribution (reuse the `sg aws cf` disable→wait→delete sequence), delete the CF Function, delete the L@E (**replicas delete asynchronously — poll until gone before the function delete succeeds**; surface a clear "waiting for L@E replicas" status), empty + delete the bucket. No orphans.

`Sentinel__Role__Profile.py` self-registers a `sentinel` family `Schema__AWS__Role__Profile` (mirror `cf_role_profile`): actions for `cloudfront:*Function*` + distribution CRUD, `lambda:*` (create/publish/get/delete + `GetFunction`), `s3:CreateBucket/PutObject/GetObject/ListBucket/DeleteObject` scoped to the log bucket, and `iam:PassRole` for the L@E execution role. **The L@E execution role trust policy must include both `lambda.amazonaws.com` and `edgelambda.amazonaws.com`.** Add it to `AWS__Role__Profiles._ensure_loaded()`.

`Sentinel__Deployer__In_Memory` injects the existing `*__AWS__Client__In_Memory` doubles so the entire create/destroy/teardown lifecycle is unit-tested with no AWS and no network.

CLI: `sg sentinel deploy create|destroy|teardown` (mutation-gated, `aws_auth_guard(family='sentinel')`, banner, confirm) and `sg sentinel status`.

## Phase 5 — Live Run + The Parity Matrix (The Definition Of Done)

The parity matrix is the headline test. The canonical request set, run through every available target, must yield **identical signals, identical log records, identical enforcement**. Local-direct is the baseline (always available); docker and AWS are asserted equal to it and run integration-tier (`@skipUnless`).

```python
CANONICAL = [                                                  # (method, path, ip, expected_verdict, expected_rule)
    ('GET', '/index.html',    '198.51.100.2', 'allow', '0001'),
    ('GET', '/etc/passwd',    '185.10.10.10', 'block', '0012'),
    ('GET', '/wp-login.php',  '91.20.20.20',  'block', '0018'),
    ('GET', '/.env',          '77.30.30.30',  'block', '0014'),
    ('GET', '/index.html',    '10.0.0.6',     'block', '0003'),   # 10.0.0.6 in BANNED_IPS fixture
    ('GET', '',               '203.0.113.5',  'block', '0007'),   # malformed (empty path)
]

def signal_of(target, req):
    captured = build_captured(req)                              # deterministic: fixed request_id + received_at for comparison
    if   target == LOCAL_DIRECT: sig = harness_direct(captured)
    elif target == LOCAL_DOCKER: sig = harness_docker(captured)
    elif target == AWS:          sig = harness_aws(captured)     # hit the live ephemeral distribution, read x-sentinel-signal
    return normalise(sig)                                        # zero out non-deterministic fields (timestamps) for comparison

def test_parity_baseline_local_direct():
    for req in CANONICAL:
        sig = signal_of(LOCAL_DIRECT, req)
        assert sig.verdict == req.expected_verdict
        assert sig.rule_id == req.expected_rule

@skipUnless(docker_available())
def test_parity_docker_equals_baseline():
    for req in CANONICAL:
        assert signal_of(LOCAL_DOCKER, req) == signal_of(LOCAL_DIRECT, req)

@skipUnless(aws_creds_available() and SENTINEL_TEST_DISTRIBUTION())
def test_parity_aws_equals_baseline():
    for req in CANONICAL:
        assert signal_of(AWS, req) == signal_of(LOCAL_DIRECT, req)
```

Plus a live smoke test: deploy → curl the distribution for each canonical path → assert HTTP 403/404 for blocks, 200/origin for allow, and that a log object landed in S3 for every request → teardown → assert no orphans.

## Definition Of Done (Maps To Arch-Brief Acceptance Criteria)

1. `sg sentinel --help` and `sn` alias work. (Phase 0)
2. L1 emits a valid `Schema__Sentinel__Signal`; never writes, never returns a response. (Phase 1)
3. L2 enforces + writes; identical logic across targets. (Phase 2)
4. Logging: benign traffic → replayable record in the sink. (Phase 2)
5. Blocking: six rules block/deflect with logged reason. (Phase 2)
6. Local-direct offline. (Phase 2)
7. Local-docker offline. (Phase 3)
8. Live ephemeral AWS CF + L@E, cache-disabled. (Phase 4–5)
9. Three-target parity. (Phase 5)
10. `deploy create/destroy/teardown` mutation-gated, no orphans. (Phase 4–5)
11. Reuses `aws/*` clients + `_shared`; Type_Safe; in-memory doubles; no mocks. (all phases)
12. Live-path cost noted vs Firehose+WAF baseline. (Phase 5)

## Gotchas (Where The Live Target Bites — Don't Learn These The Hard Way)

- **CF Functions:** no `Buffer`/`btoa`/`require`/`module`/`process`; ES5.1+limited ES. Keep `sentinel_l1.js` exactly as specified. Header value = raw JSON string.
- **Lambda@Edge:** no environment variables (template config into the zip); must author in **us-east-1**; deploy a **numbered version**, not `$LATEST`; execution-role trust needs `edgelambda.amazonaws.com`; replicas delete **asynchronously** (teardown must wait).
- **Cache:** the MVP distribution is **cache-disabled** so origin-request L@E sees every hit. Cache-hit logging needs a viewer-response path — out of scope (GAP 4.1).
- **One object per log record** for the MVP (no S3 append); note batching as a follow-up. Same key layout local and S3 so `trace`/replay match.
- **Parity comparison** must normalise non-deterministic fields (timestamps, AWS request id) before asserting equality; the *rule decision* is what must match.

## Open Items Deferred To The Next Brief (Do Not Build)

Fingerprint/fast-track; L2 rule evaluation/anomaly scoring; Layer 3 async/LLM; fractal-graph traversal; rules-as-vault; evidence/compliance graphs; threat-intel; multi-CDN; SSL termination; cache-hit logging; the TUI; log batching; IP-escrow privacy mode.

## Relationship To Previous Briefs

| Document | Relationship |
|---|---|
| `v0.27.59__arch-brief__sg-sentinel-mvp-implementation-architecture.md` | The architecture this implements file-by-file |
| `v0.27.58__addendum__…` | GAP 1.1 (L2 = log owner), 4.1 (cache-hit deferred), 6.1 (HTTP block actions), replay-from-S3 |
| `SGraph-AI__Service__Playwright` `aws/*` + `_shared` | The clients/conventions reused verbatim |

---

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