⚠ Not built. SG/Sentinel is a published design from May 2026 — “this is how I would build it” — not a product. No plans to build it unless somebody funds it. Read the note →
sg-sentinel.sgit.ai / architecture

The layer model, the signal spine, and the three targets

One governing correction shaped the whole MVP: Layer 1 never acts and never writes — it only decides and signals. Layer 2 is the sole actor and the sole I/O owner. Everything on this page follows from that sentence.

Why the correction is load-bearing

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 design (where L1 "blocked" inline). Both use cases flow through one path:

Use caseL1L2
Logging (real-time visibility)signals allowwrites the log record to the sink
Blocking (obvious-bad)signals block + reason + rule + actionwrites the log record and enforces (403/404 instead of forwarding)

There is no special-casing: a block is simply a logged action with an enforcement side-effect. And because L2 is a dumb actor in the tiny core — it enforces and writes, it never re-evaluates 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.

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. The Python schema, verbatim from the codebase:

# sentinel/schemas/Schema__Sentinel__Signal.py — THE parity spine. L1 (JS) emits this
# exact shape (snake_case keys); L2 (Python) deserialises + validates it before acting.
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     = Enum__Sentinel__Verdict.ALLOW
    reason          : Safe_Str__Sentinel__Reason
    rule_id         : Safe_Str__Sentinel__Rule_Id
    action          : Enum__Sentinel__Action      = Enum__Sentinel__Action.PASS
    layer           : Enum__Sentinel__Layer       = Enum__Sentinel__Layer.L1
    engine_version  : Safe_Str__Sentinel__Version
    ruleset_version : Safe_Str__Sentinel__Version

Transport differs per target; the schema does not. On AWS the envelope rides as a request header — x-sentinel-signal, carrying raw compact JSON (CloudFront Functions have no Buffer/btoa, and JSON contains no CR/LF, so it is a valid header value). Locally it is passed in-process from the L1 harness to the L2 module. Either way L2 deserialises into the same Type_Safe object and validates it by construction before acting.

Layer 1: the engine, in full

The L1 engine is a single dependency-free file, CloudFront Functions 2.0-compatible, runnable under plain Node — the same bytes run on CloudFront (via handler), under Node (a guarded tail), and inside the Docker CF-env simulation. This is the complete decision-and-signal path, verbatim from sentinel_l1.js:

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 handler(event) {                     // CloudFront Functions entry (viewer-request)
  // ... build the captured object from event.request ...
  r.headers['x-sentinel-signal'] = { value: JSON.stringify(evaluate(c)) };
  return r;                                   // ALWAYS return the request; L1 never acts
}

The engine runs the rules in order; first block wins; otherwise allow. The empty BANNED_IPS array in the file is an inlining marker — the deployer (for CloudFront) and the local harness (for node/docker) replace it with the list from rules.embedded.json before use, so the file stays the single source.

Layer 2: the sole actor

The L2 actor is identical logic everywhere; only two injected dependencies swap — the signal transport adapter (header on AWS, in-process locally) and the log sink (S3 on AWS, local FS or in-memory locally). Its whole job, verbatim from Sentinel__L2__Actor.py:

class Sentinel__L2__Actor(Type_Safe):
    log_sink     : Log__Sink
    privacy_mode : Safe_Str = Safe_Str('hash')                    # 'hash' (default) | 'plain' | 'omit'

    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__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)
            return     Schema__Sentinel__Enforcement(pass_to_origin=False, http_status=403)
        return         Schema__Sentinel__Enforcement(pass_to_origin=True,  http_status=0)

The log record lands in the sink under the same key layout everywhere — <prefix>/YYYY/MM/DD/HH/<request_id>.json, one object per record — so trace and replay behave identically local and on AWS. Source IPs are hashed by default (privacy mode); the record is replayable, carrying enough fidelity to re-run rules against it later.

The three targets: same engine, swapped transport and sink

A · live AWS ephemeral distribution, cache-disabled CloudFront Function (L1) viewer-request Lambda@Edge (L2) origin-request · every hit (TTL 0) S3 bucket x-sentinel-signal hdr B · local-direct fully offline, zero AWS node sentinel_l1.js (L1) same file, guarded tail Sentinel__L2__Actor (L2) same Python module local-FS sink in-process C · local-docker CF-environment simulation HTTP listener → node L1 same file, CF-like runtime Sentinel__L2__Actor (L2) same Python module local-FS sink signal over HTTP

Same L1 bytes, same L2 logic. Only the signal transport and the log sink swap. The parity matrix drives the same canonical request set through every available target and asserts identical signals, records, and enforcement.

Target A specifics — the live AWS path

CloudFront association rules permit a CloudFront Function on viewer-request and a Lambda@Edge on origin-request. Lambda@Edge on origin-request runs only on cache miss, so to make "log every hit + enforce every hit" literally true, the ephemeral test distribution runs cache-disabled (TTL 0): every request is a cache miss, so every request reaches L2 and is logged and enforceable. Cache-hit logging (which needs a viewer-response path) is a known, explicitly deferred gap — obvious-bad paths are never legitimately cached anyway, so blocking is unaffected.

The gotchas the deployer handles — recorded because each one bites:

Where it lives: sg sentinel, a top-level peer surface

Sentinel lands in the existing sg CLI as a top-level product surface — sg sentinel, alias sn — beside sg edge, not inside it. SG/Edge runs workloads; SG/Sentinel guards what sits in front of them: distinct names, distinct roles, related infrastructure. Sentinel composes the existing sg aws primitives rather than opening its own AWS seams:

sg sentinel  (product surface; this MVP)consumes as libraries
   ├── sg aws cf      → CloudFront clients + function publish
   ├── sg aws lambda  → Lambda deployer (L@E packaging)
   ├── sg aws s3      → the log sink on AWS
   ├── sg aws logs    → function/edge log inspection
   └── _shared        → auth, mutation gate, tagging, region, confirm, banner

House rules inherited from the codebase, non-negotiable: Type_Safe schemas everywhere (no Pydantic, no raw dicts as contracts); one boto3 seam per client, overridden in tests by dict-backed in-memory doubles (no mocks, no patches, no network in unit tests); mutating deploys gated by SG_AWS__SENTINEL__ALLOW_MUTATIONS=1 plus explicit confirmation.

The parity matrix is the definition of done

The same canonical request set — a benign GET /index.html, /etc/passwd, /wp-login.php, /.env, a banned-IP request, a malformed request — is driven through every available target and asserted to produce identical signals, identical log records, and identical enforcement decisions. Local-direct is the always-available baseline; docker and AWS legs are integration-tier and skip cleanly when unavailable. Non-deterministic fields (timestamps, AWS request IDs) are normalised before comparison; the rule decision is what must match. Any divergence between targets is a parity bug and a P1.

Where to read more. The full architecture is in the v0.27.59 MVP architecture brief and the file-by-file v0.27.60 dev brief; the layer model's design history is in the execution-model brief and the rules-of-the-game behavioural spec.