argus-darkroom — build doc
argus-darkroom 0.2.1.dev7+gaafcbd6 @ aafcbd6PyPI (opens in new tab)Source (opens in new tab)Changelog (opens in new tab)
Imported from spec.md on argus-darkroom's dev branch at that commit. Fixes go upstream, not here.
Guarded image generation for the Argus suite. A prompt is checked before anything is generated. Each output is checked twice, once on its own and once alongside the prompt that made it, and only then released or destroyed. Generation is done by ComfyUI and prompt expansion by Ollama, both external; darkroom orchestrates them, judges the results and keeps the record. Its demo UI is argus-safelight (opens in new tab). This doc is the design and the reasoning behind it. README.md covers running it, and CHANGELOG.md covers what has shipped.
argus-quarry -> argus-curator -> argus-lens -> argus-forge -> trainer -> argus-proof
│
validated LoRA│
▼
argus-darkroom
Darkroom sits off the main line, like argus-atlas: it uses what the line produces (a checkpoint and a LoRA) and feeds nothing back into it. It is not proof. Proof generates from prompts it trusts (the curator manifest) and returns scores. Darkroom generates from prompts it does not trust and enforces verdicts by destroying output.
What already exists
Do not rebuild these.
- ComfyUI (
:8188) generates. Darkroom sends it a workflow and collects the image over the websocket. Image-to-image needs one custom node of darkroom's own,DarkroomLoadImageBase64(vendored incomfyui/), which reads the input image from a base64 string in the graph instead of from ComfyUI'sinput/folder. Inpainting also needsDarkroomLoadMaskBase64, in the same file, which does the same for the mask. - Ollama (
:11434), optional, expands a short prompt. It is never a guard. - SingGuard server: agent-composer's
/api/guard/check(:9150, Ollama GGUFSingGuard-4b). It acceptsinput, an optionalimage_base64,input_role,policytext andthinking(fast,fast-slow,slow,cascade), and answers{safe, verdict, rule, rule_text, explanation, categories, model, thinking, elapsed_ms}. A text-only check (no image) is how pre-flight works. Darkroom depends on that PR (agent-composer #4) being merged. A separate argus moderation-service package may replace it later; the client must not care which. - NudeNet server, over HTTP. Never imported (AGPL).
- argus-cortex (
>=0.4.6,<0.5):moderation.SingGuardClient/NudeNetClient/exposed_score.- New in 0.4.2:
SingGuardClient.check_text(), andmoderation.policies: the named policy texts (moved from curator'sguard.py) and the per-policy table of illegal rules. server.WriteGuard/cross_site_refuse/env_flag.wire.make_versioned_baseandrender_schemafor the record's wire schema.
- curator's face and age pipeline (
argus_curator.faces.detect_faces,argus_curator.mivolo). It is optional, behind darkroom's[age]extra, which pullsargus-curator[faces]and runs in process.detect_facestakes(rel, abs, bytes)items, so the image is judged from memory and nothing is written to disk. MiVOLO needs its ONNX file (mivolo-convert). When the extra or the weights are missing, the age check is skipped and/healthsays so. - proof's generation models.
ModelRef,LoRARefandSamplingParamsalready describe a reproducible run. Darkroom copies their field names exactly and does not import proof. They move into cortex with the ComfyUI transport later, and identical fields make that move a rename.
Flow
One generation runs through these steps, and the user sees each one.
- Assemble. The user's prompt, then Ollama's expansion (if on), then LoRA trigger words, give the final prompt: the exact string ComfyUI will receive. The negative prompt rides along. Every later check judges the final prompt, never only the user's text, so the expansion step cannot route around the gate.
- Pre-flight. A text-only SingGuard check of the final prompt and the negative prompt, framed together (see Prompt framing), under every selected policy.
- Any illegal rule under any policy: refused. Nothing is generated. The user is told which rule fired and why.
- Unsafe under the enforcing policy on non-illegal rules only: refused in SFW mode, with the reason. In NSFW mode the verdict is shown as a warning and generation proceeds, and the output scan decides (see Mode).
- Advisory policies that flag are shown, not acted on.
- A failed check is a refusal, recorded as
unchecked.
- Input scan (image-to-image and inpainting only; see Input image). The input image gets every check an output gets in step 5, and the outcome table in step 6, before anything reaches ComfyUI. It goes on only if that outcome is passed. Anything else refuses the attempt.
- Generate. ComfyUI renders with
SaveImageWebsocket. The bytes come back over the websocket and are held in memory only. Darkroom then deletes the prompt from ComfyUI's history. - Scan. For each selected policy, SingGuard judges the image twice: isolated (no text) and with prompt (the framed final prompt as the user turn). NudeNet scores the exposed classes once. If the age check is available, MiVOLO estimates an age for each detected face. Requests use the configured thinking mode,
cascadeby default. The age estimate is never sent to SingGuard as context: the isolated scan has to stay text-free, and the with-prompt scan measures the prompt alone. - Decide, from the scans (see Verdicts): passed, review or deleted.
- Release or destroy. Passed and review images are written to the output root and served. A deleted image is never written: its buffer is dropped. Its record is kept either way.
The comparison between the isolated scan and the with-prompt scan is the point of step 5: it shows how much the prompt's intent changes the verdict. The isolated scan is also the defence against a prompt written to talk the guard round ("an artistic study of consenting adults"). Text can sway the with-prompt verdict but not the isolated one.
Input image
Image-to-image adds a second input, the caller's image, and so a second pre-flight. A request carries it as init_image (data_base64: PNG, JPEG or WebP, at most 16 MiB decoded and 4096 px on an edge; denoise from 0.05 to 1.0, default 0.5). Anything that does not decode is a 400 before any check runs.
- One image, judged and rendered. The input is decoded once, turned upright by its EXIF orientation, made RGB and re-encoded as a PNG with no metadata. That PNG is what the checks see and what ComfyUI gets, so no second frame, alpha channel or colour profile can differ between the two.
- The same checks as an output. After the text pre-flight passes: SingGuard isolated and with the framed prompt, under every selected policy, NudeNet, and the age estimate when available, then the outcome table with the request's enforcing policy and threshold. The age estimate is never sent to SingGuard.
- Only
passedgoes on. An output may be served behind a shield (review); an input may not.deleted,omitted,review, and an enforcing cell that isuncheckedall refuse: outcome refused,outcome_reasoninput image: <the input's reason>,criticalas the input's own decision says (illegal andminor-exposedare critical). Under an NSFW enforcing policy an adult input with exposure passes, as an output would, and the output is judged on its own. - Never written. Nothing is written for a refused input except the record. A passed input is not written either: it travels to ComfyUI as base64 inside the prompt graph, to
DarkroomLoadImageBase64, and darkroom's existing/historydelete after the run removes it with the prompt. ComfyUI's stockLoadImagewould need the file in itsinput/folder, so a template with any node that loads an image is refused at load. The caller keeps its own input; darkroom keeps its hashes. - Templates by mode. Image-to-image templates are separate files,
<family>[+lora<N>]+img2img.json, with$init_image(the loader'simage) and$denoise. A request with an input and no such template for its family and LoRA count is a 400; darkroom never falls back to text-to-image.
Inpainting
Inpainting is image-to-image with a mask: init_image.mask_base64 (PNG, JPEG or WebP, the same size and byte limits as the image), white where the input is repainted and black where it is kept. Only the masked region changes, at whatever denoise the request asks (inpainting usually wants a high one); every other pixel of the output is the input's.
- The mask is checked as a file, not judged as a picture. It must decode, and once turned upright by its EXIF orientation, as the image is, it must be exactly the size of the (upright) input image. It is made greyscale and cut at 128 into black and white; a mask with nothing white is a 400. It is re-encoded as a 1-bit PNG with no metadata, and that is what ComfyUI gets. Anything else is a 400 before any check runs.
- The mask is not scanned. It is a region selector: one bit per pixel saying "repaint here", with no imagery of its own. What could be harmful is the input it selects from, which gets its full pre-flight exactly as for image-to-image (and goes on only on
passed), and the output, which is scanned and decided like any other. A mask cannot make a passed input show something the output scan does not then see. So the mask is sent to no guard: SingGuard, NudeNet and the age check see the input and the output only. - Never written. It travels to ComfyUI as base64 inside the prompt graph, to
DarkroomLoadMaskBase64, and the/historydelete after the run removes it with the input. The record keeps its hashes and the share of the input it masks. - Unmasked pixels stay the input's. The example templates scale the mask exactly as the image (to
$widthx$height, cropped to the centre), grow its edge by 8 pixels so the repaint blends, restrict the sampler's noise to it (SetLatentNoiseMask), and composite the decoded result over the scaled input through the same grown mask (ImageCompositeMasked). The VAE round trip would otherwise shift every pixel slightly; the composite keeps every pixel outside the grown mask exactly the scaled input's. - Templates by mode. Inpainting templates are
<family>[+lora<N>]+inpaint.json, with$init_image,$init_mask(the mask loader'smask, exactly one) and$denoise. Only an inpainting template may contain the mask loader. A request with a mask and no such template is a 400; darkroom never drops the mask and renders image-to-image instead.
Reference images (IP-Adapter and the like) are not supported.
Policies
- Requests name policies; they never send policy text. The names come from
argus_cortex.moderation.policies. - Enforcing policy: one per request. Its verdict decides pre-flight, and its name sets the mode. The server default (
ARGUS_DARKROOM_POLICY) isadult-granularwhen NSFW is allowed, anddefaultwhen it is not. - Advisory policies: zero or more extra policies run for comparison. They never block or release an image, except that an illegal rule under any policy still deletes.
- During development, all five names are available:
default,adult,adult-granular,adult-cautiousandadult-cinematic. At release onlydefaultand one adult policy remain, whichever adult variant the ongoing evaluation picks. The default moves to that one. - "Configurable" means picking the enforcing and advisory policies and the thinking mode. SingGuard returns a verdict, not a score, so it has no threshold to tune. The NudeNet threshold is a real number and is configurable (see Verdicts).
Mode
The mode comes from the enforcing policy's name; it is not a separate field. SingGuard's default rules treat sexual content as a risk, so default is SFW. Any policy named sfw-* is SFW too: that prefix is reserved for finer-grained SFW policies added later. Every other policy is NSFW. Cortex owns this rule (policies.mode(name)), next to the illegal table, so a new policy cannot end up with a mode that doesn't match its name.
NSFW policies can enforce only when the server is started with --allow-nsfw (ARGUS_DARKROOM_ALLOW_NSFW=1), the same pattern as curator's --allow-move. Without the flag:
- a request that names an NSFW policy as enforcing gets HTTP 400;
/healthreportsnsfw_allowed: false, and/policiesmarks the NSFW policies as advisory-only.
NSFW policies can still run as advisory under an SFW enforcing policy, for comparison. Mode changes two things: what pre-flight blocks, and whether NudeNet exposure omits the image or blurs it. The illegal table and the delete rules are the same in both modes.
Illegal rules
Each policy has its own table, because rule letters mean different things under each one:
Under SingGuard's own default rules, A is "Sexual Content Risk", which also covers consensual adult nudity. Treating it as illegal would delete every adult image whenever default is chosen for comparison, so default can only send an image to review. B (Real-World Crimes) and G (Animal Abuse) go to review, not deletion, under every policy. The table lives in cortex next to the policy texts, so a policy and its illegal rules cannot drift apart.
Verdicts
Each scan cell (one policy × isolated or with prompt) has one of four states:
The outcome comes from all the cells:
- deleted if any cell is
illegal, under any policy, enforcing or advisory, in either context. - deleted if any enforcing cell is
unchecked. This fails closed, and costs little: the record holds every parameter, so the same seed regenerates the same image once the guard is back. Anuncheckedadvisory cell is shown as grey and changes nothing. - review if any cell is
flagged. The image is served with its shields. This covers the scans disagreeing and the scans all agreeing on a non-illegal rule. - passed if every cell is
safe.
NudeNet under an SFW enforcing policy. An SFW request must not return nudity, even behind a shield, so NudeNet acts as a gate:
- The image's exposed score is under 0.2 (NudeNet's reporting floor, on the five default exposed classes): the outcome is decided by the cells above. A non-safe cell still means review behind its shield.
- The score is 0.2 or more: omitted, with
outcome_reason: "sfw-exposed". The user'snsfw_thresholdhas no effect on this. - NudeNet does not respond, or
NUDENET_URLis empty: omitted, withoutcome_reason: "sfw-unchecked".
An omitted image is dropped the same way a deleted one is: never written, its buffer discarded. That is to save space and keep SFW output clean, not because the content is criminal, so its record is marked critical: false. Records for illegal or minor-exposed deletions are critical: true. When record retention is decided, the flag lets non-critical records be pruned first. Deletion rules still take priority: an image that is both illegal and exposed is deleted, not omitted.
Without a NUDENET_URL, every SFW output would be omitted. So the server refuses SFW generation (HTTP 400, naming the missing NudeNet), and /health reports sfw_available: false.
NudeNet under an NSFW enforcing policy. NudeNet never omits or deletes on its own here, because it cannot see age or consent. Its exposed score against the request's nsfw_threshold (default 0.2) sets blur: true on the served image, so safelight blurs it until the user clicks to reveal. If NudeNet does not respond, blur is true.
Age estimate (when available). The minor-exposed rule makes the outcome deleted, with outcome_reason: "minor-exposed", when a MiVOLO age under 18 shares the image with exposure NudeNet scores at 0.2 or more on one of its five default exposed classes. ARGUS_DARKROOM_MINOR_EXPOSED picks how the exposure is placed; an unknown value refuses to start:
frame(the default): any estimate under 18, from a face or a faceless person, anywhere in the frame. Where the exposure is does not matter, so an adult's exposure beside a clothed minor deletes too. This is the rule darkroom has always applied.overlap: argus-curator's originalminor_exposedrule. With person boxes (else face boxes), each exposed NudeNet detection scoring 0.2 or more deletes the image when it overlaps a box estimated under 18 or overlaps no box at all; one overlapping only adults' boxes does not. A matched person takes its face's estimate. It fails closed: a face under 18 whose body was not found makes any exposure count, as does an exposed score with no exposed detection box to place.
Both ignore the user's NudeNet threshold. An age under 18 without exposure does not delete; it is recorded and shown. The record keeps every person box and its face link (age.people), NudeNet's detection boxes (nudenet.detections), and the rule that decided (age.minor_exposed_rule), so an overlap decision can be recomputed from the record. Faceless people are known from age.people (a null face_index), never from their position in faces. When the age check is unavailable, the record says age: null and the minors rule rests on SingGuard alone.
The age is a tool's estimate from a face, and darkroom says so everywhere it appears. The record names its source (mivolo) and stores the estimate, never a stated age. The API field is age_estimate_years, not age. Safelight labels it as "MiVOLO estimate". A wrong estimate can delete an adult's image or, worse, pass a minor's. The second case is why the age check is an extra signal on top of SingGuard's minors rule, never a replacement for it.
Prompt framing
SingGuard reads a bare description as a request; curator's guard_context.py exists to tell it otherwise. In darkroom the text really is a request, and the framing says so.
Image generation request.
Prompt: <final prompt>
Negative prompt (what the image must not show): <negative prompt>
The negative prompt is always part of pre-flight. The with-prompt scan sends the same text as the user turn, followed by the image. Labelling the negative prompt matters: a negative of child, minor, underage is a common safety practice and must not read as asking for those things. The other direction matters too: negating clothing in the negative prompt is a known way to get nudity. The pre-flight evaluation covers both directions on labelled prompts. If this framing fails either, the framing changes; the negative prompt stays in the check.
Record
A record is kept for every attempt, including refused and deleted ones. It is the only thing left of a deleted image, so it must be enough to regenerate it exactly.
GenerationRecord (record_version "1.0", wire schema committed)
id, created_at, darkroom_version
outcome: refused | passed | review | deleted | omitted | failed
outcome_reason: rule codes, "minor-exposed", "unchecked", "sfw-exposed", "sfw-unchecked", "input image: " and one of those, or the generation error (a render of one colour is a generation error: there is nothing to judge)
critical: bool # true for illegal and minor-exposed deletions; false for everything else
request
mode: sfw | nsfw # derived from guard.enforcing_policy, stored for filtering
user_prompt, expanded_prompt (null if Ollama is off), final_prompt, negative_prompt
expansion: {model, system_prompt_sha256} | null
seed
sampling: SamplingParams # sampler, scheduler, steps, cfg, guidance (null unless set), clip_skip, width, height
checkpoint: ModelRef # name + sha256
vae: ModelRef | null
loras: [LoRARef] # name, sha256, weight
workflow_sha256, workflow (the template's name, e.g. "sdxl+lora1"), engine "comfyui", engine_version
init_image: {sha256, phash, width, height, source_sha256, denoise, mask} | null # image-to-image; never the pixels
mask: {sha256, source_sha256, masked_fraction} | null # inpainting; never the pixels
guard
enforcing_policy, advisory_policies, thinking
preflight: {policy: GuardResult}
scans: {policy: {isolated: Cell, with_prompt: Cell}} # Cell = state + GuardResult | error
nudenet: {exposed_score, labels, detections: [{label, score, bbox}], threshold, blur} | null # boxes, never pixels
age: {source "mivolo", faces: [{age_estimate_years, bbox}], people: [{bbox, age_estimate_years | null, face_index | null}],
minor_exposed, minor_exposed_rule "frame" | "overlap"} | null # tool estimate
input: {scans, nudenet, age, outcome, reason} | null # the input image's own scan (image-to-image)
image
sha256, phash, width, height # always, including when deleted
path # only when passed or review
A deleted record has no pixels, thumbnail or preview of any kind; those would be the image again. Its sha256 and pHash let a repeat of the same bad output be recognised. Model sha256s are read from the ComfyUI models root (ARGUS_DARKROOM_MODELS_ROOT) and cached by path and mtime, since hashing a checkpoint on every request is too slow.
What it does not do
- Write anything before the verdict. No temporary file, no cortex blob or lineage write, no
SaveImageorPreviewImagenode. A workflow that contains a node that writes to disk is refused when it is loaded, not when it runs. - Reference images (IP-Adapter and the like). Each is another picture that would need its own pre-flight. Image-to-image has one: see Input image. An inpainting mask is not a picture, so it needs none: see Inpainting.
- Training, captioning or scoring against a dataset. Those are forge, lens and proof.
- Accept policy text in a request. Policies are chosen by name only.
- Public hosting. Not planned in the short or medium term. The server binds to localhost by default.
Shape
safelight :3000 ──▶ argus-darkroom :8106 ──▶ ComfyUI :8188
record store Ollama :11434 (optional)
output root SingGuard :9150 (agent-composer)
NudeNet
The package is scaffolded from argus-pkg-template with the CLI, server and Dockerfile options on. It has three layers: an engine (pydantic plus cortex, stdlib HTTP for ComfyUI), an optional Typer CLI (generate, preflight, serve, schema) and an optional FastAPI server.
Server (argus-darkroom serve --port 8106 --output-root …):
GET /health: version, which backends answer, the policy list,nsfw_allowed,read_only.GET /policies: names, the enforcing default and each policy's illegal rules. Safelight shows the wording.GET /models: checkpoints and LoRAs found under the models root, skipping any diffusers folder (one holdingmodel_index.json), anddefault_checkpoint, the one a form should start on (null whenDEFAULT_CHECKPOINTmatches nothing).POST /preflight: the text check alone, so the form can show a verdict before Generate.POST /generate/stream: SSE eventspreflight,input_scan(image-to-image and inpainting: one per finished input cell),queued,progress(ComfyUI step),scan(one per finished cell),complete(the record). One generation at a time; a second gets HTTP 409.GET /records: paged, filterable by outcome.GET /record/{id}.GET /image/{id}: ids only, no path input. A deleted, omitted or refused record answers 404 with the record id.DELETE /record/{id}: the user removing their own output. This deletes the file; the record stays, marked as removed by the user.GET /archive: a streamed zip of every record (JSON) and every stored image, which means passed and review images only, because deleted and omitted ones were never written. It is read-only and always available.POST /purge: deletes every record and stored image, and clears darkroom's prompts from ComfyUI's history. A clean slate. It exists only whenARGUS_DARKROOM_DEVMODE=1, and answers 404 otherwise, so hiding the button is not the only thing preventing a purge. The body must be{"confirm": "purge"}. A purge during a generation gets HTTP 409./healthreportsdevmode.POST /purge/{id}: deletes one record and its stored image, with the same confirm body, the same refusal during a generation, and the same 404 withoutARGUS_DARKROOM_DEVMODE=1. Refused and deleted records are the audit trail, so outside devmode a record can only lose its image, throughDELETE /record/{id}.WriteGuardandcross_site_refusefrom cortex. CORS allows reads by default; writes are allowed only from the configured origins (ARGUS_DARKROOM_CORS_ORIGINS).
Config (ARGUS_DARKROOM_*): COMFYUI_URL, OLLAMA_URL (empty turns expansion off), OLLAMA_MODEL, SINGGUARD_URL, NUDENET_URL, POLICY, THINKING (default cascade), OUTPUT_ROOT, MODELS_ROOT, DEFAULT_CHECKPOINT (default sdxl: an exact name, or a folder for the first model in it), WORKFLOW (a template file for every checkpoint, or a directory of <family>.json / <family>+lora<N>.json templates, each optionally ending +img2img or +inpaint, chosen per request by the checkpoint's top-level folder, LoRA count and whether there is an input image and a mask; nothing fitting is a 400, never a substitute), ALLOW_NSFW, DEVMODE, READ_ONLY (403 on every write, POST /preflight included, because a pre-flight spends a SingGuard call). Compose maps the .env value DEVMODE=1 to ARGUS_DARKROOM_DEVMODE, and the flags are read with cortex's env_flag, which warns about a mistyped value instead of silently ignoring it. An empty SINGGUARD_URL makes the server refuse to start. Darkroom never runs unguarded.
Store: one flat <id>.json per record, the same pattern as curator's ScanStore. Images go under <output_root>/<id>.png.
Testing
No real sexual, violent or illegal content goes in the repo or the tests.
- Fake SingGuard, NudeNet and ComfyUI servers, in the pattern of cortex's
_Fake. The illegal-to-deleted path is driven by a fake verdict, not by an image. - SFW NudeNet tests: an exposed score of 0.19 is served, 0.2 is omitted, a NudeNet timeout is omitted, an image that is both exposed and illegal is deleted (with
critical: true), and SFW generation withoutNUDENET_URLgets HTTP 400. - Purge tests:
POST /purgeanswers 404 withoutDEVMODE, 409 during a generation, and 400 without the confirm body. After a purge, records, images and darkroom's ComfyUI history are empty.GET /archivetaken before the purge holds every record. - A test that a deleted or omitted outcome leaves no bytes anywhere: the output root, the system temp directory and the fake ComfyUI history. The test lists all three before and after.
- A test that a workflow containing a disk-writing node is refused at load.
- Image-to-image: an input refused for each reason (illegal, flagged under SFW, exposed under SFW, minor-exposed, SingGuard or NudeNet unreachable) reaches no part of ComfyUI and leaves no bytes, listed as above; a text refusal comes first and the input is never scanned.
- Inpainting: the input is scanned exactly as for image-to-image and the mask reaches no guard; a refused input with a mask reaches no part of ComfyUI and the mask's bytes are in no file; a mask of the wrong size, that does not decode, or that masks nothing is a 400 before any check.
- Mode tests:
defaultandsfw-*names are SFW, and everything else is NSFW.- A non-illegal pre-flight flag refuses under an SFW enforcing policy and proceeds under an NSFW one; an illegal flag refuses under both.
- Without
--allow-nsfw, an NSFW enforcing policy gets HTTP 400, and the same policy as advisory is accepted.
- Unit tests over the full outcome table (every combination of cell states), and per policy for the illegal table.
- The age check through a fake
detect_faces: an under-18 estimate with exposure deletes, one without exposure does not, and with the extra missing the record getsage: nulland the scan carries on. - A pre-flight evaluation script, in the spirit of curator's
scripts/guard_eval.py: labelled text prompts, including negative-prompt cases in both directions, run against the real server. Run by hand, not in CI.
Open questions
None are blocking the build.
Settled: the enforcing policy sets the mode (default and sfw-* are SFW; the rest are NSFW), and non-illegal pre-flight flags block in SFW mode only (Mode). The negative prompt is always part of pre-flight (Prompt framing). The MiVOLO age estimate is used when available, and presented as a tool's estimate (Verdicts).
Deferred until before a public launch:
- Which adult policy ships. This follows the evaluation of the three variants. The default moves with it.
- Record retention. Keep records forever, or prune by age? Refused and deleted records are the audit trail. Until this is decided, records are kept.
Build order
The order the first release was built in, kept for its reasoning. Steps 1 to 7 are done. Step 8 is still open: darkroom still has its own ComfyUI client.
- Cortex 0.4.2, a separate PR in argus-core:
check_text()andmoderation.policies(the texts, the illegal table andmode()). Curator then imports its policies from cortex (a PR into curatordev). - Engine without generation. Assemble the prompt, frame it, run pre-flight, compute the outcome table and write records, all tested against fakes. Run the pre-flight evaluation against :9150.
- ComfyUI. Load the workflow and refuse disk-writing nodes, render through
SaveImageWebsocket, clear history, hash the models. Use darkroom's own minimal client for now. - Post-generation scans and release or destroy, with the no-bytes test. Then the
[age]extra and theminor-exposedrule. - Server and SSE, then the CLI.
- Ollama expansion.
- Schema, README, CLAUDE.md, compose. Compose runs darkroom and safelight; ComfyUI, Ollama, SingGuard and NudeNet are reached by URL.
- Later, in their own PRs: lift the ComfyUI transport and the generation models into cortex, and move atlas and proof onto them.
Repo conventions from day one: main for releases, dev for work, PRs into dev (.cursor/rules/git-workflow.mdc). Lint, tests and schema --check run locally before every PR into dev, and CI runs them on every push and PR to dev and main. Versions come from git tags (hatch-vcs).
Done when
- A prompt flagged under the enforcing policy is refused with its rule and explanation, and ComfyUI never sees a request.
- A generated image whose scan comes back illegal (from a fake verdict) leaves no bytes on disk or in ComfyUI's history, and its record holds every parameter needed to regenerate it.
- A record's seed and parameters, replayed, give the same image sha256 on the same ComfyUI and models.
- A review image is served with every cell's verdict, and a disagreement between the isolated and with-prompt scans shows up in the record.
- With SingGuard unreachable, nothing is served.
- No SFW request returns an image with a NudeNet exposed score of 0.2 or more, or one NudeNet never scored.