Threads · 09 / 18 · Sep 7, 2026 · Apache-2.0

Refusal reasons are product design, inside a security layer

Six situations that need six different people to do six different things, compressed into one bit. The owner reads "it did not run" and cannot tell whether to change a switch, grant a role, configure a ceiling — or do nothing at all, because it already happened.

Six refusals, six remedies

{ kind: "not_enabled" }                          // the owner turns the capability on
{ kind: "not_permitted", role: "guest" }         // an admin grants the role, or somebody else acts
{ kind: "needs_approval", autonomy: "approve" }  // a human approves — it is waiting, not lost
{ kind: "no_cap" }                               // configure a ceiling
{ kind: "over_cap", amount, cap }                // approve this one, or raise the ceiling
{ kind: "duplicate", firstRef }                  // nothing: it already happened, and here is where

The test that makes the argument is one line:

expect(new Set(casos.map((c) => remedy[c.kind])).size).toBe(6);

Six distinct things to do. If any two refusals had the same remedy they could share a name; none do, so none can.

And a typed refusal has to carry its payload. over_cap without the ceiling sends you to look it up; duplicate without the reference leaves you wondering whether it really happened. A refusal with no data is a nicer-looking boolean.

The half that is always missing

exercise(audit, "billing.credit.issue", { ...base, cap: null });

expect(audit.rows).toEqual([{ action: "billing.credit.issue", outcome: "no_cap", … }]);

A block is recorded as carefully as a success. "What the agent did not do, and why" is exactly what somebody needs in order to understand a configuration — and it is the part that cannot be reconstructed later, because nothing happened to leave a trace.

The boolean version records nothing when it declines:

exerciseBoolean(audit, "billing.credit.issue", { ...base, cap: null });
expect(audit.rows).toHaveLength(0);

An agent that is silently doing nothing looks identical to an agent with nothing to do. Weeks later somebody asks why it never acted, and there is nothing to read.

Why the gap stays invisible

expect(a1.rows[0]!.outcome).toBe("executed");   // typed version
expect(a2.rows[0]!.outcome).toBe("executed");   // boolean version

Both record the success. The audit table looks healthy either way — it fills up with everything that happened — and the difference only shows the day you go looking for something that did not.

This is not error handling

It is the same information the product needs anyway. A refusal is a state of the product, not an exception: needs approval means it is waiting, duplicate means it already worked, no cap means somebody has a setting to fill in. Modelling them as failures throws away the difference between "come back later", "you are done" and "there is a thing for you to do".

Which is also why they belong to the security layer rather than beside it. The layer that decides is the only one that knows why, and it is the only place the reason can be recorded without being reconstructed.

Where this comes from

The gate in niiko declines with a named reason and writes the refusal to the same audited table as a success, because "the agent did not do this, and why" turned out to be the question people actually asked — far more often than "what did it do".

License

Apache-2.0 — see LICENSE. This is a demonstration, not a package. Copy what you need.


Built by Vorluno — a software studio from Panamá.

// next threadRow-level security leaks when the connection is pooled