# AiSOC — one command per thing you actually want to do.
#
# There is exactly one canonical answer to "how do I run this", and it lives
# here. `docker compose` still works directly; these targets are the supported
# spelling and are what the README and CI use, so they cannot drift from the
# documentation without a red build.
#
# Profiles (see docs/architecture/README.md):
#   core  — default. postgres, redis, kafka, ingest, fusion, api, web, agents,
#           the LLM gateway and the local model behind it, and the threat-intel
#           feed with its vector store. The smallest deployment that can take an
#           event, produce an alert, and triage it with a real model — with no
#           credentials at all. ~8GB RAM.
#   full  — core plus the event lake (ClickHouse), entity graph (Neo4j),
#           full-text search (OpenSearch), enrichment and scheduled connectors.
#           ~12GB RAM.
#   demo  — core plus clearly-labelled synthetic data.

.DEFAULT_GOAL := help

PYTHON  ?= python3
# `docker-compose.ports.yml` is generated by `scripts/resolve_port_conflicts.py`
# when a host port AiSOC publishes is already in use. It is a second `-f`
# rather than a `docker-compose.override.yml` on purpose: compose picks the
# override file up implicitly for *every* command, including ones run from a
# shell by hand, and a file that silently changes what `docker compose ps`
# reports is a trap. Naming it here keeps the remap visible.
# `$(shell ...)` in a recursively-expanded variable, not `$(wildcard)` in a
# simply-expanded one. `:=` and `$(wildcard)` are both evaluated when make
# *parses* this file — which is before `_ports` has had a chance to write
# the overlay — so the first `make up` on a host with a port conflict
# generated the file correctly and then started compose without it, and
# failed on exactly the bind it had just resolved.
COMPOSE ?= docker compose $(shell test -f docker-compose.ports.yml && printf -- '-f docker-compose.yml -f docker-compose.ports.yml')
PROFILE ?=

# `--profile full` when PROFILE=full, nothing otherwise.
PROFILE_ARG := $(if $(PROFILE),--profile $(PROFILE),)

# The address `up` prints for the console. README tells operators deploying
# somewhere that is not a laptop to set AISOC_CONSOLE_URL "so the printed
# address is the one people browse to" — and `up` printed a hardcoded
# http://localhost:3000 regardless, two lines above `bootstrap`, which reads
# the variable and printed the configured address. An operator on a host
# reached by its LAN address got both, the wrong one first, and localhost
# resolves on their machine to something that is not this console.
#
# Recursively expanded on purpose: `up` depends on `env`, which creates .env,
# so this has to be read when the recipe line runs rather than when the
# Makefile is parsed.
#
# Only the console line is derived. `API:` below stays loopback because it is
# accurate as written — the API publishes on 127.0.0.1:8000 of this host — and
# a console URL behind a reverse proxy says nothing about where the API port
# is, so deriving one from the other would trade a wrong address for a guess.
CONSOLE_URL = $(shell sed -n 's/^AISOC_CONSOLE_URL=//p' .env 2>/dev/null | tail -n1 | tr -d '\r')
#
# The port the console was actually published on, which is not always
# 3000. `scripts/resolve_port_conflicts.py` moves a published port when
# the host already has it, and writes the mapping into
# `docker-compose.ports.yml`. A clean-room run found 3000 taken, the
# console served happily on 15000, and `make up` printed 3000 — a first
# impression of a product that does not start.
#
# Read from the overlay rather than re-probed, because the overlay is
# what compose acted on; probing again would answer a different question
# and could disagree.
# Two things this expression has to avoid, both of which produced a
# `Makefile: *** unterminated call to function 'shell'` rather than a
# wrong answer. A sed capture group's `\(` `\)` unbalances the parens
# `make` is counting, and a literal `#` anywhere in the command starts a
# `make` comment that swallows the closing paren. So: no capture group,
# and the pattern starts after the comment marker.
#
# The overlay line reads `  # web: 3000 was in use, published on 15000
# instead`, so the digits are the original and then the replacement.
CONSOLE_PORT = $(shell grep -m1 'web: 3000 was in use, published on' docker-compose.ports.yml 2>/dev/null | tr -cd '0-9 ' | tr ' ' '\n' | grep -v '^$$' | tail -n1)
console_url = $(if $(strip $(CONSOLE_URL)),$(strip $(CONSOLE_URL)),http://localhost:$(if $(strip $(CONSOLE_PORT)),$(strip $(CONSOLE_PORT)),3000))

.PHONY: help install env up up-dev up-full up-gpu up-host-llm _gpu_preflight pull down restart status doctor smoke _anonymous_write_is_refused demo logs clean \
        bootstrap ingest-token api-token test test-unit test-integration test-e2e stats papers \
        papers-install demo-script replay-eval replay-eval-verify

help:
	@echo ""
	@echo "  Getting started"
	@echo "    make install        Install prerequisites, configure, start, and verify"
	@echo "    make env            Create .env and generate its secrets (run by make up)"
	@echo "    make up             Start the CORE stack (postgres, kafka, ingest, fusion, api, web)"
	@echo "    make up-full        Start CORE plus lake, graph, vector, search and enrichment"
	@echo "    make bootstrap      Create the first administrator and print its password once"
	@echo "    make ingest-token   Mint the credential POST /v1/ingest/batch requires"
	@echo "    make smoke          Push one real event through the pipeline and check it becomes an alert"
	@echo "    make doctor         Diagnose the deployment and say what to fix"
	@echo ""
	@echo "  Upgrading"
	@echo "    git pull && make up   Fetches new images when AISOC_VERSION is a moving tag"
	@echo "                          (the default). A pinned release is immutable and is not refetched."
	@echo ""
	@echo "  Running it"
	@echo "    make status         Show every service and its health"
	@echo "    make pull           Refresh the images (what \`make up\` does on a moving tag)"
	@echo "    make logs           Follow logs (SERVICE=fusion to narrow)"
	@echo "    make restart        Restart the stack"
	@echo "    make down           Stop the stack, keep the data"
	@echo "    make clean          Stop the stack and delete all volumes"
	@echo "    make demo           Load clearly-labelled synthetic data"
	@echo ""
	@echo "  Developing"
	@echo "    make test           Unit tests for every service"
	@echo "    make test-e2e       The golden pipeline against a running stack"
	@echo "    make stats          Recount the figures the README publishes"
	@echo ""

# ── Getting started ────────────────────────────────────────────────────────

install:
	./install.sh

# Creates .env from .env.example and writes a real random value for each secret
# that is empty or still a placeholder. Idempotent: a second run leaves values
# it already generated alone, so `up` can depend on it unconditionally.
#
# This runs *before* compose because compose only interpolates `.env`; there is
# no `env_file` directive anywhere in docker-compose.yml. A value that is wrong
# at `up` time is baked into every container's environment until the next `up`.
env:
	@$(PYTHON) scripts/ensure_env.py

# The port check runs before compose, not after it fails. `docker compose up`
# reports a conflict as `Bind for 127.0.0.1:5432 failed: port is already
# allocated` against whichever container lost the race, which names neither the
# process holding the port nor what to do about it. Half the stack is running
# by then, so the error also arrives after a minute of unrelated output.
up: env _ports _refresh
	@# UP_FLAGS is the documented way to pass compose's own flags through
	@# without a second code path for starting the stack. install.sh's
	@# --rebuild uses it (`make up UP_FLAGS=--build`), which is how that
	@# flag reaches compose on the Unix side while install.ps1 appends
	@# --build to its own invocation. Empty by default.
	$(COMPOSE) up -d $(UP_FLAGS)
	@echo ""
	@echo "Waiting for services to become healthy…"
	@$(MAKE) --no-print-directory _wait
	@echo ""
	@echo "  Console:  $(console_url)"
	@echo "  API:      http://localhost:8000  (spec: docs/openapi.yaml; interactive"
	@echo "            docs are off in this production-class stack)"
	@echo ""
	@echo "Prove the pipeline works:  make smoke"
	@$(MAKE) --no-print-directory bootstrap

# Creates the first administrator and prints the password once. Idempotent: a
# second run reports the existing account and changes nothing, which is why
# `up` can call it unconditionally.
#
# Failure here is reported but does not fail `up`. The stack is genuinely
# running at this point, and a `make up` that exits non-zero over an account
# the operator can create with one more command would be the wrong signal —
# but it must say so, because silence would leave them at a login form with no
# credential and no explanation.
# `make bootstrap ARGS=--reset-password` replaces the password of an account
# that already exists, which is the answer to "the terminal scrolled away".
bootstrap:
	@$(COMPOSE) run --rm -T api python -m app.scripts.bootstrap_admin $(ARGS) || { \
	  echo ""; \
	  echo "Could not create the administrator — the stack is up, but you cannot sign in yet."; \
	  echo "Run 'make doctor' to find out why, then 'make bootstrap' again."; \
	}

# The lake and graph writers target stores that exist only in `full`, so the
# flags travel with the profile. In CORE they default off rather than
# retrying forever against a host that is not there.
#
# `_ports` runs here for the same reason it runs for `up`, more so: this
# starts twenty-one services, so the bind failure compose reports arrives
# even later and against even more unrelated output. Observed: a conflict on
# 8123 stopped the stack after eight containers had already started, with
# `Bind for 0.0.0.0:8123 failed: port is already allocated` naming neither the
# process holding it nor what to do.
# The developer path, explicit. `make up` is production-class now: an
# uncredentialed request gets a 401, because the value of ENVIRONMENT used to
# decide whether a host was anonymous and it defaulted to `development`.
# This target layers the overlay that turns the conveniences back on.
#
# Two `-f` flags rather than `include:`, which imports a model whose services
# may not be overridden. The overlay was written that way and could therefore
# never have carried an override, which is why it carried nothing.
#
# The bypass is still refused if the stack publishes a non-loopback address,
# so this makes a laptop convenient rather than a server open.
up-dev: env _ports _refresh
	$(COMPOSE) -f docker-compose.yml -f infra/compose/docker-compose.dev.yml up -d
	@$(MAKE) --no-print-directory _wait
	@echo ""
	@echo "  Console:  $(console_url)"
	@echo "  API:      http://localhost:8000  (spec: docs/openapi.yaml; interactive"
	@echo "            docs are off in this production-class stack)"
	@echo ""
	@echo "  Anonymous access is ON. This overlay is for a laptop, not a host"
	@echo "  anyone else can reach."
	@echo ""
	@$(MAKE) --no-print-directory bootstrap

up-full: env _ports _refresh
	AISOC_LAKE_WRITER_ENABLED=true AISOC_GRAPH_ENABLED=true $(COMPOSE) --profile full up -d
	@$(MAKE) --no-print-directory _wait PROFILE=full
	@echo ""
	@echo "  Console:  $(console_url)"
	@echo "  API:      http://localhost:8000  (spec: docs/openapi.yaml; interactive"
	@echo "            docs are off in this production-class stack)"
	@echo ""
	@echo "Prove the pipeline works:  make smoke"
	@$(MAKE) --no-print-directory bootstrap

# ── Where the model runs ─────────────────────────────────────────────────────
#
# Three options, and the default is unchanged: `make up` runs the bundled
# Ollama on CPU, which works everywhere and needs nothing.
#
#   make up-gpu       bundled Ollama, on an NVIDIA GPU   (Linux, Windows+WSL2)
#   make up-host-llm  an Ollama you already run yourself (the only option that
#                     uses Apple Silicon's GPU, since Docker Desktop cannot
#                     pass Metal into a Linux container)
#
# A hosted provider is the fourth option and needs no target at all: configure
# it per tenant from the console, Settings -> Deployment & AI, or from the
# first-run wizard.

# `_gpu_preflight` first, deliberately. Without it the failure on a host with
# no NVIDIA container toolkit is the daemon's own
# `could not select device driver "nvidia"`, which names neither a cause nor a
# next command.
up-gpu: env _gpu_preflight _ports _refresh
	$(COMPOSE) -f docker-compose.yml -f infra/compose/docker-compose.gpu.yml up -d
	@$(MAKE) --no-print-directory _wait
	@echo ""
	@echo "  Console:  $(console_url)"
	@echo "  API:      http://localhost:8000"
	@echo ""
	@echo "  Ollama is running with an NVIDIA device reserved. Confirm the model"
	@echo "  actually loaded onto it:  docker compose exec ollama ollama ps"
	@echo "  (a SIZE with 100% GPU is what you want; 100% CPU means it did not)"
	@echo ""
	@$(MAKE) --no-print-directory bootstrap

_gpu_preflight:
	@python3 scripts/check_gpu_runtime.py

# Uses an Ollama already listening on the host. The bundled one is not started
# at all, so there is no second copy and no port conflict to resolve.
up-host-llm: env _ports _refresh
	$(COMPOSE) -f docker-compose.yml -f infra/compose/docker-compose.host-llm.yml up -d
	@$(MAKE) --no-print-directory _wait
	@echo ""
	@echo "  Console:  $(console_url)"
	@echo "  API:      http://localhost:8000"
	@echo ""
	@echo "  Using your host's Ollama. The bundled one was not started."
	@echo "  If triage reports no model, check it is listening on all interfaces:"
	@echo "    OLLAMA_HOST=0.0.0.0 ollama serve"
	@echo ""
	@$(MAKE) --no-print-directory bootstrap

# Refreshes the images when the tag they name can move.
#
# `pull_policy: missing` is correct for a pinned tag — `v12.0.0` is immutable,
# so once it is local there is nothing to fetch — and wrong for `latest`, which
# is republished on every merge to main. With `AISOC_VERSION=latest` (the
# default) and `missing` on all 16 first-party services, `git pull &&
# make up` ran brand-new compose configuration against whatever images the
# machine happened to have. Nothing anywhere told an operator to pull.
#
# Measured: a stack whose images were pulled at 08:14 UTC stayed on them
# across `make up` while `ghcr.io/beenuar/aisoc-web:latest` had been
# republished at 15:29 UTC, seven commits later. The revision label says so,
# and nothing in the output did.
#
# So this pulls when the tag can move and does nothing when it cannot:
#
#   * a first run downloads the same bytes either way, so it costs nothing;
#   * a warm run on `latest` spends ~25s on manifest checks and gets the fix
#     the operator just pulled, which is the point;
#   * a pinned `AISOC_VERSION` skips it entirely and prints why;
#   * `AISOC_PULL_POLICY=never` (also honoured by compose itself) skips it,
#     for an air-gapped host that must never reach a registry.
_refresh:
	@version="$${AISOC_VERSION:-$$(sed -n 's/^AISOC_VERSION=//p' .env 2>/dev/null | tail -1)}"; \
	version="$${version:-latest}"; \
	policy="$${AISOC_PULL_POLICY:-$$(sed -n 's/^AISOC_PULL_POLICY=//p' .env 2>/dev/null | tail -1)}"; \
	if [ "$$policy" = "never" ]; then \
	  echo "AISOC_PULL_POLICY=never — not contacting a registry; running whatever is local."; \
	elif [ "$$version" = "latest" ] || [ "$$version" = "main" ] || [ "$$version" = "edge" ]; then \
	  echo "AISOC_VERSION=$$version is a moving tag — refreshing images (pin a release to skip this)."; \
	  $(COMPOSE) $(PROFILE_ARG) pull --quiet || echo "  (pull failed — continuing with the local images)"; \
	else \
	  echo "AISOC_VERSION=$$version is pinned and immutable — nothing to refresh."; \
	fi

# The same refresh on its own, for an operator who wants to fetch now and
# start later, or who is diagnosing a version mismatch.
pull: _refresh

# Names a port conflict before compose hits it. Silent when every port is
# either free or already held by this deployment's own containers — re-running
# `make up` on a running stack must not be reported as a conflict with itself.
_ports:
	@# Resolve rather than refuse. This used to stop the stack and tell the
	@# operator to go edit docker-compose.yml, which is a hard stop at step
	@# one of the quick start for the most common condition there is: a
	@# Postgres on 5432, or an Ollama on 11434 — and the reader of this
	@# README is exactly the person who has one.
	@#
	@# Only the published side moves. Service-to-service traffic uses
	@# container ports and service names, so nothing inside the stack
	@# changes.
	@python3 scripts/resolve_port_conflicts.py || { \
	  echo ""; \
	  echo "Could not find free ports to move to, which is unusual."; \
	  echo "Run './scripts/doctor.sh --ports-only' to see what is held."; \
	  echo ""; \
	  exit 1; \
	}

# Waits on the services that declare a healthcheck, and refuses to call a
# stack up while any container is dead.
#
# The old loop read `ps` without `-a` and looked only at Health. An *exited*
# container is absent from that listing entirely and a *restarting* one
# reports no health, so both were invisible: `make up-full` printed
# "Full profile up." with OpenSearch OOM-killed and threatintel in a crash
# loop. A container that is not running is the one thing a wait loop must
# never score as success.
#
# `exited` alone is not that thing, though, and reading it as such broke the
# documented command on every machine. `ollama-pull` is a one-shot: it fetches
# the model and exits 0, and `litellm` waits on
# `service_completed_successfully`, so by the time `docker compose up -d`
# returns the puller has *always* exited. `make up` therefore ended in
# "These services are not running: ollama-pull" on the first run and every
# run after it — a stack that was entirely healthy, reported as broken, by
# the step whose job is to say whether it is healthy. The exit code is what
# separates the two cases: 0 is a one-shot that did its job, anything else is
# a container that died.
#
# The fields are `|`-separated rather than space-separated because a service
# with no healthcheck prints an *empty* Health, and awk's default splitting
# collapses the run of whitespace — so `ollama-pull exited  0` parsed as three
# fields with the exit code in $$3 and nothing in $$4, and the new exit-code
# test read an empty string on exactly the row it was written for.
_wait:
	@for i in $$(seq 1 60); do \
	  status=$$($(COMPOSE) $(PROFILE_ARG) ps -a --format '{{.Service}}|{{.State}}|{{.Health}}|{{.ExitCode}}' 2>/dev/null); \
	  pending=$$(echo "$$status" | awk -F'|' '$$3=="starting"{print $$1}'); \
	  broken=$$(echo "$$status" | awk -F'|' '($$2=="exited"&&$$4!="0")||$$2=="dead"||$$2=="restarting"||$$3=="unhealthy"{print $$1}'); \
	  if [ -n "$$broken" ]; then \
	    echo ""; \
	    echo "These services are not running: $$broken"; \
	    for s in $$broken; do echo "  docker compose logs $$s | tail -30"; done; \
	    if $(COMPOSE) logs --tail 80 $$broken 2>/dev/null | grep -qiE "no space left on device|disk quota exceeded"; then \
	      echo ""; \
	      echo "  Those containers died because the Docker VM ran out of disk,"; \
	      echo "  not because anything is wrong with them. CORE needs ~20GB."; \
	      echo ""; \
	      echo "      docker system prune -af --volumes"; \
	      echo ""; \
	      echo "  then 'make up' again. 'make doctor' reports free space up front."; \
	    fi; \
	    echo "Run 'make doctor' for the full picture."; \
	    exit 1; \
	  fi; \
	  [ -z "$$pending" ] && exit 0; \
	  sleep 3; \
	done; \
	echo "Still not healthy after 3 minutes: $$pending"; \
	echo "Run 'make doctor' to find out why."; \
	exit 1

down:
	$(COMPOSE) --profile full --profile monitoring --profile chatops --profile extras --profile osquery down

restart: down up

status:
	@$(COMPOSE) ps --format 'table {{.Service}}\t{{.State}}\t{{.Status}}\t{{.Ports}}'

doctor:
	@./scripts/doctor.sh $(if $(filter full,$(PROFILE)),--full,)

# The credential POST /v1/ingest/batch requires. Idempotent: an existing
# active push token is returned rather than a second one being minted, so
# `smoke` can call it on every run. `make ingest-token ARGS=--rotate`
# replaces it.
ingest-token:
	@$(COMPOSE) run --rm -T api python -m app.scripts.mint_ingest_token $(ARGS)

# A short-lived bearer for the account `make bootstrap` created, so a script
# can read the API the way the console does. The sibling of `ingest-token`:
# reading used to need no credential at all, because an uncredentialed request
# resolved to a demo administrator and every documented path produced one.
api-token:
	@$(COMPOSE) run --rm -T api python -m app.scripts.mint_api_token $(ARGS)

# The golden pipeline. One real event, through the real spine, observed from
# outside. This is the only claim of "it works" the project makes.
#
# The token is minted first because ingest is authenticated: the endpoint
# takes a credential that carries its own tenant rather than a header
# naming one. Minting here keeps `make smoke` a single command.
smoke: _anonymous_write_is_refused
	@token="$$($(COMPOSE) run --rm -T api python -m app.scripts.mint_ingest_token --quiet)" || { \
	  echo "Could not mint an ingest token — is the stack up? Try 'make doctor'."; \
	  exit 1; \
	}; \
	api="$$($(COMPOSE) run --rm -T api python -m app.scripts.mint_api_token --quiet)" || { \
	  echo "Could not mint an API token — has 'make bootstrap' run? Try 'make doctor'."; \
	  exit 1; \
	}; \
	AISOC_INGEST_TOKEN="$$token" AISOC_API_TOKEN="$$api" $(PYTHON) tests/e2e/golden_pipeline/run_golden_pipeline.py

# Both surfaces, because they are different doors and only one of them was
# ever described. The console proxy forwards /api/v1/* to the API, so a stack
# that refuses on :8000 and serves on :3000 is still anonymous to anyone with
# a browser — and :3000 is the one the single-host guide tells operators to
# publish.
#
# A connection error is a skip, not a pass: "nothing answered" and "the right
# thing answered" must not print the same word.
_anonymous_write_is_refused:
	@echo "Checking that an unauthenticated write is refused…"
	@# The console target comes from $(console_url), not a literal 3000.
	@# When a port conflict moves the console, a hardcoded 3000 probes
	@# nothing and the failure below reads as a security hole.
	@#
	@# And the code is read without `|| echo 000`: curl already prints 000
	@# on a connection failure *and* exits non-zero, so the fallback
	@# appended a second one. The result was "000000", which matched
	@# neither branch and fell through to FAIL — reporting an unreachable
	@# port as "an uncredentialed caller was served", which is the most
	@# alarming thing this script can say and was not true.
	@for target in "http://localhost:8000/api/v1/cases" "$(console_url)/api/v1/cases"; do \
	  code=$$(curl -s -o /dev/null -w '%{http_code}' -X POST "$$target" \
	    -H 'Content-Type: application/json' -d '{"title":"anonymous write probe"}' \
	    --max-time 10 2>/dev/null); \
	  code=$${code:-000}; \
	  if [ "$$code" = "000" ]; then \
	    echo "  SKIP  $$target did not answer (not running?)"; \
	  elif [ "$$code" = "401" ] || [ "$$code" = "403" ]; then \
	    echo "  OK    $$target refused with $$code"; \
	  else \
	    echo "  FAIL  $$target answered $$code to a request carrying no credential."; \
	    echo "        An uncredentialed caller must never be served. If you meant to"; \
	    echo "        run the developer overlay, that is 'make up-dev' and it is not"; \
	    echo "        safe on a host anyone else can reach."; \
	    exit 1; \
	  fi; \
	done

demo:
	@echo "Loading synthetic demo data. Every row is tagged is_synthetic=true"
	@echo "and the console labels it. See 'Real vs synthetic data' in README.md."
	$(COMPOSE) run --rm -e AISOC_ALLOW_SEED=1 api python -m app.scripts.seed_demo

logs:
	$(COMPOSE) logs -f --tail=200 $(SERVICE)

clean:
	$(COMPOSE) --profile full --profile monitoring --profile chatops --profile extras --profile osquery down -v
	@echo "Stack stopped and volumes deleted."

# ── Tests ──────────────────────────────────────────────────────────────────

test: test-unit

test-unit:
	@set -e; for svc in api agents fusion actions connectors; do \
	  if [ -d "services/$$svc/tests" ]; then \
	    echo "── services/$$svc"; \
	    (cd "services/$$svc" && $(PYTHON) -m pytest tests/ -q) || exit 1; \
	  fi; \
	done

test-integration:
	@$(PYTHON) -m pytest tests/ -q --ignore=tests/e2e

test-e2e: smoke

# ── Project figures ────────────────────────────────────────────────────────
# Every quantitative claim in the README is produced by this, so a figure
# cannot be edited by hand into something the tree does not support.
stats:
	@$(PYTHON) scripts/project_stats.py

# ── Replay evaluation: grade AiSOC on your own closed alerts ───────────────
#
# Reads a window of closed findings out of your SIEM, replays them through
# the same triage path a live alert takes, scores the verdicts against what
# your analysts decided, and writes one signed file.
#
# It writes nothing to your deployment: the replay runs in shadow mode and
# the count of writes it intercepted travels in the bundle, so a replay that
# quietly stopped replaying can be told from one whose writes were suppressed.
#
# Air-gapped by default. If a hosted model answered, the run is refused
# unless you passed --hosted, and the refusal names the model. The check
# reads the models that actually answered rather than a config file, because
# a config file states an intention.
#
#   make replay-eval ARGS="--connector-id <uuid>"
#   make replay-eval ARGS="--connector-id <uuid> --since 2026-07-01T00:00:00Z --until 2026-10-01T00:00:00Z"
#   make replay-eval ARGS="--connector-id <uuid> --export-items"   # per-decision rows, redacted
#   make replay-eval-verify ARGS="aisoc-replay-<id>.bundle.json"
#
# Credentials: AISOC_API_KEY carries the tenant, so there is no tenant flag.
# AISOC_REPLAY_SIGNING_KEY (or SECRET_KEY) signs the bundle, so it verifies
# on this deployment and nowhere else.
replay-eval:
	@$(PYTHON) scripts/run_replay_eval.py run $(ARGS)

replay-eval-verify:
	@$(PYTHON) scripts/run_replay_eval.py verify $(ARGS)

# ── Papers + demo script (pre-existing) ────────────────────────────────────

papers:
	$(PYTHON) scripts/render_white_paper.py --all

papers-install:
	$(PYTHON) -m pip install --quiet 'markdown>=3.5' 'weasyprint>=60'

demo-script:
	@cat docs/demo/SCREENCAST_SHOTLIST.md
