Slotting Architecture · 18 min read

Enforcing Role- and Zone-Scoped Access Boundaries for Slotting

Slotting optimization runs as a continuous control loop that reconciles inventory velocity, physical storage constraints, and pick-path efficiency, and every stage of that loop is a place where an unauthorized or malformed configuration change can corrupt live fulfillment. This guide is part of the Core Slotting Architecture & Velocity Taxonomies system, and it treats access boundaries as first-class, deterministic constraints on the optimizer rather than administrative overhead bolted on afterward. An uncontrolled write to a velocity-decay threshold or an affinity weight does not just log an audit line — it re-ranks thousands of SKUs, floods material handlers with relocation tasks, and can strand this week’s fast movers in reserve. The job of an access boundary is to make sure that only a scoped, validated, and reversible change ever reaches the constraint solver.

What a Slotting Access Boundary Enforces

An access boundary for slotting is a policy-driven middleware layer that intercepts every configuration request before it reaches the optimization solver and resolves it against three orthogonal axes:

  • Action scope (role-based). What class of operation the caller may perform — read a rule set, propose a change, approve a zone, override a velocity threshold, or write directly to the live configuration. This is classic RBAC, expressed as a permission matrix mapping roles such as slotting_analyst, warehouse_ops_lead, and slotting_engineer to sets of allowed actions.
  • Spatial scope (zone-based). Which physical zones the caller may touch. A floor supervisor who owns ZONE_A must not be able to rewrite bulk-storage parameters in COLD_STORAGE or override hazardous-material segregation rules, even if their role technically permits the action elsewhere. Spatial scope is checked against the location graph defined in Location Hierarchy Mapping.
  • Temporal / approval scope. Whether a high-impact change may execute immediately or must route through a staged approval chain. A single affinity-weight tweak on one aisle is low blast-radius; a facility-wide velocity reclassification during a peak throughput window is not.

The industry-standard variant of this pattern is attribute-based access control (ABAC), where zone ownership and change magnitude become just more attributes in a policy expression. For most slotting stacks a hybrid is cleaner: coarse RBAC for the action verb, an explicit zone-ownership check for the spatial axis, and a magnitude threshold for the approval axis. The edge case that catches teams out is the system_admin wildcard — a role that bypasses action scope must still be logged and, ideally, still bounded by the approval axis so that no single credential can silently re-slot an entire building.

A slotting config request passing through the access boundary A left-to-right flow. A POST to /slotting/config enters four sequential gates: identity (verify JWT signature and expiry, hydrate zones), action scope (the RBAC role-to-action matrix), zone scope (the requested zone must be in the caller's owned assignments), and a magnitude gate that classifies blast radius. Passing gate one is required to reach gate two, and so on. A failed identity check returns 401; a failed action or zone check returns 403; each denial writes to an immutable append-only audit log. After the magnitude gate, low blast-radius changes are queued directly for the constraint solver while high blast-radius changes are staged for quorum approval, and both outcomes are also written to the audit log. HIGH LOW deny 401 deny 403 deny 403 every outcome logged Config request POST /slotting/config 1 Identity verify JWT sig + exp hydrate zone set 2 Action scope RBAC matrix role → action 3 Zone scope zone_scope in owned assignments 4 Magnitude classify blast radius Staged approval quorum sign-off Constraint solver queued immediately Immutable audit log append-only (WORM) · keyed by nonce · every allow, deny and stage recorded pass deny → 4xx audit write

Input Data Requirements

The boundary layer consumes two records on every request: the authenticated caller context (derived from a verified token) and the slotting configuration payload. Both must be fully typed and validated before any policy check runs — an unvalidated payload lets a type-coerced zone_scope slip past a string equality check. The canonical fields, types, and preconditions are below.

Field Type Source Precondition
user_id str JWT sub claim Non-empty, matches an active identity
roles set[str] JWT roles claim Subset of the permission matrix keys
zone_assignments set[str] Identity service Every zone exists in the location graph
token_exp float JWT exp claim Strictly greater than time.time()
rule_set_id str Request body References an existing rule set
zone_scope str Request body Matches the ^[A-Z_]{2,20}$ zone pattern
velocity_override float | None Request body 0.0 ≤ v ≤ 1.0 when present
affinity_weights dict[str, float] | None Request body Weights in [0, 10], keys are known SKU families

The quality precondition that matters most is that zone_assignments is resolved from an authoritative identity service, not copied from the token claims verbatim — tokens outlive role changes, so a zone the caller lost yesterday must not still be honored today.

Step-by-Step Implementation

The enforcement pipeline is a FastAPI dependency chain: resolve identity, check action scope, check zone scope, then either dispatch to the solver or fan out to an approval chain. Each stage emits a structured audit line so a denied request is diagnosable from logs alone.

1. Model the Permission Matrix and Request Context

Start with strongly typed models and a permission matrix that maps each role to the actions it may perform. Keep the matrix data-driven (loadable from config) so a new role never requires a code deploy. In production the matrix is Redis-backed and refreshed on a short TTL; here it is inlined for clarity.

from __future__ import annotations

import logging
import time
from typing import Dict, Optional, Set

from fastapi import Depends, HTTPException, Request, status
from pydantic import BaseModel, Field

audit_logger = logging.getLogger("slotting.access_audit")
audit_logger.setLevel(logging.INFO)


class SlottingConfigPayload(BaseModel):
    rule_set_id: str
    zone_scope: str = Field(..., pattern=r"^[A-Z_]{2,20}$")
    velocity_override: Optional[float] = Field(None, ge=0.0, le=1.0)
    affinity_weights: Optional[Dict[str, float]] = None


class UserContext(BaseModel):
    user_id: str
    roles: Set[str]
    zone_assignments: Set[str]
    token_exp: float


# Data-driven RBAC matrix (production: Redis-backed, short TTL).
SLOTTING_PERMISSION_MATRIX: Dict[str, Set[str]] = {
    "slotting_analyst": {"read", "propose"},
    "warehouse_ops_lead": {"read", "propose", "approve_zone"},
    "slotting_engineer": {"read", "propose", "approve_zone", "override_velocity", "write"},
    "system_admin": {"*"},
}

2. Resolve and Verify the Caller’s Identity

Every request carries a bearer token. Reject anything without a well-formed Authorization header before touching the payload, and verify the token has not expired against the server clock. In production this function decodes the JWT, verifies the signature against your JWKS, and hydrates zone_assignments from the identity service rather than trusting the claim blindly.

async def get_current_user(request: Request) -> UserContext:
    """Extract and validate JWT claims, then hydrate zone assignments."""
    auth_header = request.headers.get("Authorization", "")
    if not auth_header.startswith("Bearer "):
        audit_logger.warning("Rejected request with missing bearer token")
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing or invalid token")

    # In production: decode + verify signature, then resolve zones from the
    # identity service so revoked assignments are not honored via a stale token.
    user = UserContext(
        user_id="eng_user_01",
        roles={"slotting_engineer"},
        zone_assignments={"ZONE_A", "ZONE_B", "COLD_STORAGE"},
        token_exp=time.time() + 3600,
    )
    if user.token_exp <= time.time():
        audit_logger.warning("Rejected expired token for user=%s", user.user_id)
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token expired")
    return user

3. Enforce Action Scope with a Dependency Guard

The action guard is a parameterized FastAPI dependency: pass it the action a route requires, and it verifies at least one of the caller’s roles grants that action. The system_admin wildcard short-circuits the check but is still logged, so no privileged write is ever silent.

def require_slotting_permission(required_action: str):
    """FastAPI dependency enforcing RBAC action scope."""

    def dependency(user: UserContext = Depends(get_current_user)) -> UserContext:
        if "system_admin" in user.roles:
            audit_logger.info(
                "Admin override: user=%s action=%s", user.user_id, required_action
            )
            return user

        granted = any(
            required_action in SLOTTING_PERMISSION_MATRIX.get(role, set())
            for role in user.roles
        )
        if not granted:
            audit_logger.warning(
                "Permission denied: user=%s action=%s roles=%s",
                user.user_id, required_action, user.roles,
            )
            raise HTTPException(
                status.HTTP_403_FORBIDDEN,
                f"Insufficient privileges for action: {required_action}",
            )
        return user

    return dependency

4. Enforce Zone Scope Against Physical Assignments

Action scope alone is not enough: a slotting_engineer may write, but only within the zones they own. The zone check compares the payload’s zone_scope against the caller’s authoritative assignments and denies cross-zone writes with an audit line that records both the requested and the allowed set.

def validate_zone_scope(payload: SlottingConfigPayload, user: UserContext) -> None:
    """Ensure the requested zone is within the caller's physical authority."""
    if payload.zone_scope not in user.zone_assignments:
        audit_logger.warning(
            "Zone boundary violation: user=%s requested=%s allowed=%s",
            user.user_id, payload.zone_scope, user.zone_assignments,
        )
        raise HTTPException(
            status.HTTP_403_FORBIDDEN,
            f"User lacks authority to modify zone: {payload.zone_scope}",
        )

5. Route High-Impact Changes Through Staged Approval

Not every authorized change should execute inline. A facility-wide velocity reclassification or a cross-aisle swap must route through an asynchronous approval chain so it cannot detonate during peak throughput. The magnitude gate classifies a payload by blast radius, and only low-magnitude changes reach the solver directly; the rest are staged for cross-functional sign-off.

from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum


class ChangeMagnitude(str, Enum):
    LOW = "low"        # single-zone weight tweak — immediate
    HIGH = "high"      # velocity override or multi-zone — staged approval


@dataclass
class StagedChange:
    rule_set_id: str
    zone_scope: str
    requested_by: str
    magnitude: ChangeMagnitude
    approvals: Set[str] = field(default_factory=set)
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

    def is_approved(self, quorum: int) -> bool:
        return len(self.approvals) >= quorum


def classify_magnitude(payload: SlottingConfigPayload) -> ChangeMagnitude:
    """A velocity override is always high blast-radius; weight tweaks are low."""
    if payload.velocity_override is not None:
        return ChangeMagnitude.HIGH
    if payload.affinity_weights and len(payload.affinity_weights) > 5:
        return ChangeMagnitude.HIGH
    return ChangeMagnitude.LOW

6. Wire the Route and Emit an Immutable Audit Trail

The route ties the stages together. Action scope is enforced as a dependency, zone scope and magnitude are checked in the handler, and every accepted change is written to an append-only audit log keyed by a cryptographic nonce so change tracking is deterministic and tamper-evident. The immutable log is what satisfies compliance audits and lets you replay exactly which credential authorized which re-slot.

import secrets

from fastapi import FastAPI

app = FastAPI()


@app.post("/api/v1/slotting/config")
async def update_slotting_rules(
    payload: SlottingConfigPayload,
    user: UserContext = Depends(require_slotting_permission("write")),
):
    validate_zone_scope(payload, user)
    magnitude = classify_magnitude(payload)
    nonce = secrets.token_hex(16)

    if magnitude is ChangeMagnitude.HIGH:
        staged = StagedChange(
            rule_set_id=payload.rule_set_id,
            zone_scope=payload.zone_scope,
            requested_by=user.user_id,
            magnitude=magnitude,
        )
        audit_logger.info(
            "Change staged for approval: nonce=%s rule_set=%s zone=%s user=%s",
            nonce, payload.rule_set_id, payload.zone_scope, user.user_id,
        )
        return {"status": "pending_approval", "nonce": nonce, "requested_by": staged.requested_by}

    audit_logger.info(
        "Slotting config accepted: nonce=%s rule_set=%s zone=%s user=%s",
        nonce, payload.rule_set_id, payload.zone_scope, user.user_id,
    )
    return {"status": "queued_for_solver", "nonce": nonce, "payload_id": payload.rule_set_id}

The full production hardening of this enforcement layer — JWKS signature verification, Redis-backed policy caching, context-variable propagation, and schema-driven payload validation — is covered end to end in Implementing role-based access for slotting configs.

Tuning & Calibration

The boundary’s behavior is entirely externalized so token lifetimes, cache freshness, approval quorum, and the magnitude threshold can change without a redeploy. Two knobs dominate: the policy-cache TTL (too long and revoked roles linger; too short and every request hammers Redis) and the approval quorum for high-magnitude changes (too high and legitimate re-slots stall through peak season; too low and the staging gate is theater). Both the YAML and the equivalent Python dict are shown so the config can be loaded from a file or embedded in a settings module.

access_boundary:
  token_ttl_seconds: 3600          # JWT lifetime; keep short to bound stale roles
  policy_cache_ttl_seconds: 60     # Redis matrix refresh window
  zone_pattern: "^[A-Z_]{2,20}$"   # accepted zone_scope shape
approval:
  high_magnitude_quorum: 2         # distinct approvers required
  approval_window_hours: 4         # stage expires if unapproved in window
  affinity_weight_fanout: 5        # >N weighted families => HIGH magnitude
audit:
  nonce_bytes: 16                  # entropy per change record
  immutable_sink: "append_only"    # WORM store; never in-place update
CONFIG = {
    "access_boundary": {
        "token_ttl_seconds": 3600,
        "policy_cache_ttl_seconds": 60,
        "zone_pattern": r"^[A-Z_]{2,20}$",
    },
    "approval": {
        "high_magnitude_quorum": 2,
        "approval_window_hours": 4,
        "affinity_weight_fanout": 5,
    },
    "audit": {"nonce_bytes": 16, "immutable_sink": "append_only"},
}

Facility-specific adjustment: single-shift operations with a small slotting team can drop high_magnitude_quorum to 1 and lengthen approval_window_hours, while a 24/7 multi-zone building should raise the quorum and shorten the policy-cache TTL so role changes propagate before the next shift inherits them.

Validation & Testing

Access-control code is exactly where silent regressions hide, so assert the deny paths explicitly, not just the happy path. The checks below exercise action scope, zone scope, and magnitude classification with plain assert statements you can run under pytest.

import pytest


def _ctx(roles, zones):
    return UserContext(user_id="t", roles=set(roles), zone_assignments=set(zones),
                       token_exp=time.time() + 60)


def test_analyst_cannot_write():
    guard = require_slotting_permission("write")
    with pytest.raises(HTTPException) as exc:
        guard(_ctx({"slotting_analyst"}, {"ZONE_A"}))
    assert exc.value.status_code == status.HTTP_403_FORBIDDEN


def test_engineer_write_in_owned_zone_passes():
    guard = require_slotting_permission("write")
    user = guard(_ctx({"slotting_engineer"}, {"ZONE_A"}))
    payload = SlottingConfigPayload(rule_set_id="rs1", zone_scope="ZONE_A")
    validate_zone_scope(payload, user)  # must not raise


def test_engineer_blocked_outside_owned_zone():
    user = _ctx({"slotting_engineer"}, {"ZONE_A"})
    payload = SlottingConfigPayload(rule_set_id="rs1", zone_scope="COLD_STORAGE")
    with pytest.raises(HTTPException) as exc:
        validate_zone_scope(payload, user)
    assert exc.value.status_code == status.HTTP_403_FORBIDDEN


def test_velocity_override_is_high_magnitude():
    payload = SlottingConfigPayload(rule_set_id="rs1", zone_scope="ZONE_A",
                                    velocity_override=0.4)
    assert classify_magnitude(payload) is ChangeMagnitude.HIGH


def test_staged_change_needs_quorum():
    change = StagedChange("rs1", "ZONE_A", "eng_1", ChangeMagnitude.HIGH)
    change.approvals.update({"lead_1"})
    assert not change.is_approved(quorum=2)
    change.approvals.update({"lead_2"})
    assert change.is_approved(quorum=2)

Run these in CI on every change to the matrix or the guards; a green suite proves that adding a role or renaming an action did not accidentally widen a deny path into an allow.

Integration Points

The access boundary sits directly in front of the constraint solver, so its output is the gate every other component of the architecture depends on. Approved payloads flow into the scoring and assignment engine described in SKU Velocity Taxonomy Design — the velocity_override field a caller is or is not permitted to set is the same decay threshold that taxonomy computes. Zone scope is validated against the graph produced by Location Hierarchy Mapping, which is why zone_assignments must reference real, existing zones. And because a slotting change alters pick density, the magnitude gate should consult Pick Path Modeling Frameworks: if a proposed rule invalidates an active pick-path model or pushes travel time past a safe threshold, the boundary must halt execution and force it into the approval chain regardless of the caller’s role.

Failure Modes & Edge Cases

  • Zone assignments trusted from stale token claims. A revoked zone remains writable until the token expires. Remediation: hydrate zone_assignments from the identity service at request time and keep token_ttl_seconds short.
  • Wildcard admin bypasses the approval axis. A single system_admin credential re-slots a building with no second signer. Remediation: keep admin subject to the magnitude gate, and log every admin action to the immutable sink.
  • Policy-cache TTL too long after a role change. A demoted engineer keeps write access for the length of the cache window. Remediation: bound policy_cache_ttl_seconds to seconds and invalidate on role-change events.
  • Magnitude misclassification. A high-fanout affinity change slips through as LOW and executes inline during peak. Remediation: tune affinity_weight_fanout conservatively and treat any velocity_override as HIGH unconditionally.
  • Audit log written in place rather than append-only. An attacker or a buggy job overwrites the record of who authorized a change. Remediation: use a write-once (WORM) sink keyed by nonce and never update a record after creation.

FAQ

Why enforce zone scope separately from roles instead of just creating per-zone roles?

Per-zone roles explode combinatorially — five actions across twenty zones is a hundred role names to maintain. Keeping action scope (RBAC) orthogonal to spatial scope (zone ownership) means a slotting_engineer is defined once, and their reach is bounded by an assignment set resolved at request time. It also lets you move an engineer between zones by editing one assignment list rather than reissuing roles.

Should the system_admin wildcard skip the approval chain?

No. Action scope and approval scope are different axes: system_admin legitimately bypasses the RBAC verb check, but a facility-wide reclassification is still high blast-radius no matter who requests it. Keep admin subject to the magnitude gate so no single credential can silently re-slot an entire building, and log every admin action to the immutable sink.

How do I stop the staging gate from blocking legitimate re-slots during peak season?

Tune the quorum and window, not the gate itself. Drop high_magnitude_quorum for smaller teams, lengthen approval_window_hours so a stage does not expire before a signer is available, and pre-authorize a standing on-call approver. Removing the gate entirely trades a few minutes of latency for the risk of a peak-window slotting storm.

Where does token signature verification belong in this flow?

At the very front, inside get_current_user, before any payload parsing or policy check. Verify the JWT signature against your JWKS and reject expired tokens first; only then hydrate the authoritative zone assignments. Every downstream guard assumes the identity is already trustworthy, so a gap here undermines the whole boundary.