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 case | L1 | L2 |
|---|---|---|
| Logging (real-time visibility) | signals allow | writes the log record to the sink |
| Blocking (obvious-bad) | signals block + reason + rule + action | writes 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
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:
- CloudFront Functions: no
require,module,Buffer,btoa, or top-levelprocess; ES5.1-ish. The engine file is written to that intersection. - Lambda@Edge: no environment variables — the deployer templates a generated
_sentinel_config.pyinto the zip; must be authored in us-east-1; deployed as a numbered version, never$LATEST; the execution role must be trusted by bothlambda.amazonaws.comandedgelambda.amazonaws.com. - Teardown: Lambda@Edge replicas delete asynchronously — teardown polls until they are gone, then removes the function, the distribution (disable → wait → delete), and empties and deletes the bucket. No orphans.
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.