feat(content): wire the classifier, so deny actually denies #390

Merged
coilyco-ops merged 2 commits from feat/deny-actually-denies into main 2026-08-13 11:44:50 +00:00
Member

closes #388

The wiring for #227, and the enforcement half #228 rides on.

The taxonomy declared deny: true on six classes and nothing read it at runtime. Asking where the bus is got whatever the model decided. Lucia landed the taxonomy and prompt; I landed the bounded block response; Quail confirmed nothing gates content. The invocation between them was declined by all of us in turn.

Inert until a deployment says otherwise

SIRENS_ECHO_CONTENT_CLASSES names the taxonomy. Unset loads none, runs no classifier, and costs nothing — not a model call, not a decision, not a tag beyond content.classified: false.

That is deliberate. A gate that refuses member requests should arrive by a decision someone made, and the cost is real: one extra model call per turn, landing hardest on the cheapest turns. The full costing is on 227 and the enabling decision is Kai's.

The property that matters more than the feature

A broken gate is not a denial. A classifier that errors, times out, or answers with a class outside the closed set leaves the turn unclassified and the turn proceeds.

Treating a classifier failure as a refusal turns one broken dependency into a service that refuses everything — and does it while looking like policy working correctly. That is the failure mode worth engineering against here.

Kai's four tags

content.classified   whether the gate ran at all
content.class        which class was decided
content.approved     whether it was allowed
content.sensitive    whether the class is a sensitive one

The first earns its place by being false much of the time; a tag that is always true says nothing, and asking for a boolean is what suggested the gate should be able to not run. A turn that was never classified carries only that tag — reporting a class for a decision that never happened would make the other three lies.

sensitive stays separate from approved because it changes the refusal's shape and not its verdict: a sensitive block names no category, since naming it tells a member what to avoid saying next time.

Ordering

Classification runs on the member's own message — what was asked for is the thing being classified — and before the answering call, so a blocked turn spends no completion budget on an answer nobody will read.

Not in scope

The deterministic prefilter I costed on 227, which would cut the per-turn cost. It is a change on top of this seam, not a replacement for it.

ward exec gate green: build, policy-check, vet, test, test-skips, pre-commit.

closes #388 The wiring for https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/227, and the enforcement half https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/228 rides on. The taxonomy declared `deny: true` on six classes and **nothing read it at runtime**. Asking where the bus is got whatever the model decided. Lucia landed the taxonomy and prompt; I landed the bounded block response; Quail confirmed nothing gates content. The invocation between them was declined by all of us in turn. ## Inert until a deployment says otherwise `SIRENS_ECHO_CONTENT_CLASSES` names the taxonomy. Unset loads none, runs no classifier, and costs nothing — not a model call, not a decision, not a tag beyond `content.classified: false`. That is deliberate. A gate that refuses member requests should arrive by a decision someone made, and **the cost is real**: one extra model call per turn, landing hardest on the cheapest turns. The full costing is on 227 and the enabling decision is Kai's. ## The property that matters more than the feature **A broken gate is not a denial.** A classifier that errors, times out, or answers with a class outside the closed set leaves the turn unclassified and the turn proceeds. Treating a classifier failure as a refusal turns one broken dependency into a service that refuses everything — and does it while looking like policy working correctly. That is the failure mode worth engineering against here. ## Kai's four tags ``` content.classified whether the gate ran at all content.class which class was decided content.approved whether it was allowed content.sensitive whether the class is a sensitive one ``` The first earns its place by being **false** much of the time; a tag that is always true says nothing, and asking for a boolean is what suggested the gate should be able to not run. A turn that was never classified carries only that tag — reporting a class for a decision that never happened would make the other three lies. `sensitive` stays separate from `approved` because it changes the refusal's *shape* and not its verdict: a sensitive block names no category, since naming it tells a member what to avoid saying next time. ## Ordering Classification runs on the member's own message — what was asked for is the thing being classified — and **before** the answering call, so a blocked turn spends no completion budget on an answer nobody will read. ## Not in scope The deterministic prefilter I costed on 227, which would cut the per-turn cost. It is a change on top of this seam, not a replacement for it. `ward exec gate` green: build, policy-check, vet, test, test-skips, pre-commit.
feat(content): wire the classifier, so deny actually denies
All checks were successful
ci / test (pull_request) Successful in 31s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
ci / image-build (pull_request) Successful in 19s
a0cac51455
The taxonomy declared deny on six classes and nothing read it at runtime.
Asking where the bus is got whatever the model decided. The taxonomy, the
classifier prompt, and the bounded block response all existed; the invocation
between them did not, and every agent on this tracker declined it in turn.

A deployment-supplied path turns the gate on. Unset loads no taxonomy, runs no
classifier, and costs nothing. A gate that refuses member requests should
arrive by a decision rather than by a binary being rebuilt, and the cost is
real: one extra model call per turn, landing hardest on the cheapest turns.

A broken gate is not a denial. A classifier that errors, times out, or answers
outside the closed set leaves the turn unclassified and the turn proceeds.
Treating that as a refusal turns one broken dependency into a service that
refuses everything while looking like policy working.

The four span tags are the ones that were asked for. The first is false
whenever the gate did not run, which is what makes it worth carrying: a tag
that is always true says nothing. A turn that was never classified carries only
that tag, because reporting a class for a decision that never happened would
make the other three lies.

Sensitive stays separate from approved. It changes the refusal's shape and not
its verdict, since a sensitive block names no category.

Classification runs on the member's message and before the answering call, so a
blocked turn spends no completion budget on an answer nobody will read.

closes #388

Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Merge branch 'main' into feat/deny-actually-denies
All checks were successful
ci / test (pull_request) Successful in 31s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
ci / image-build (pull_request) Successful in 20s
840dd716be
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo!390
No description provided.