tim witter
All articles

Blog

Boundaries before convenience

When a shortcut makes a workflow smoother, I ask what authority it quietly assumes. Naming that authority costs a little effort up front and saves a much larger repair later.

Name the boundary

I distinguish a capability from the authority to exercise it. A tool may be able to read, change, or publish something; that does not tell me who may request the action, on which material, or under which conditions. Before making an interface convenient, I write down the allowed actor, target, and operation. The small inconvenience of spelling these out is cheaper than discovering later that a helpful default crossed a trust boundary.

A grant is easiest to review when it is a value rather than a convention. I keep the permitted actor, the permitted action, and the scope in one place, and let the operation ask that value directly. This keeps policy out of scattered conditionals. When the answer is no, the refusal should name the missing part — actor, action, or target — so the caller learns which permission to request.

Naming the boundary also fixes what the tool may assume. A default that reads a private file, posts to a shared channel, or spends a budget is an authority decision even when it is implemented as a convenience. I would rather make the common path slightly longer than leave the authority implicit, because implicit authority is the kind that is hardest to audit later.

policy.tsTypeScript
type Action = "read" | "publish";

interface Grant {
  actor: string;
  action: Action;
  targets: readonly string[];
}

// A capability says what the system can do;
// a grant says who may ask for it.
export function mayPerform(
  grant: Grant,
  request: { actor: string; action: Action; target: string },
): boolean {
  return (
    grant.actor === request.actor &&
    grant.action === request.action &&
    grant.targets.includes(request.target)
  );
}

Test both sides

A happy-path test shows that a permitted action works, but it does not establish where permission ends. I pair it with a denied case: a different actor, a different target, or an action outside the grant. I also check what happens when context is absent or ambiguous. If the system guesses permission in those cases, the boundary is not doing the job I intended, however tidy the interface looks.

The denied case is most useful when it is close to the allowed one: the same actor and target with a different action, or the same action on a different target. A distant failure only shows that the system is not wide open. A near miss shows where the boundary actually sits, and it is the case most likely to be broken by a later refactor that quietly widens a condition.

I also test the awkward middle: missing context, an unknown actor, or a target that no longer exists. Refusing to decide is a valid outcome, as long as the refusal is visible and someone can explain it. A boundary that silently falls back to the permissive branch is more dangerous than one that fails closed and says why.

policy.test.tsTypeScript
test("a nearby request without a grant is refused", () => {
  const grant: Grant = {
    actor: "ana",
    action: "read",
    targets: ["draft-1"],
  };

  expect(
    mayPerform(grant, { actor: "ana", action: "read", target: "draft-1" }),
  ).toBe(true);

  // Same actor and target, different action.
  expect(
    mayPerform(grant, { actor: "ana", action: "publish", target: "draft-1" }),
  ).toBe(false);

  // Same action and target, different actor.
  expect(
    mayPerform(grant, { actor: "bo", action: "read", target: "draft-1" }),
  ).toBe(false);
});

Keep friction where it matters

Not every operation needs a confirmation step. Repeated prompts can train people to approve without reading. I prefer explicit constraints close to the operation and a deliberate pause for actions that are hard to reverse or expose someone else's work.

Friction is a tool, not a virtue. I place it where a mistake would be expensive and keep it away from repeated read-only steps. The review question is practical: can I explain why a request was accepted or refused, and can I reproduce each result without depending on a fortunate default?