Skip to content

security: a branch bound key never acts outside its branch - #83

Open
futurebuildai wants to merge 66 commits into
refactor/v1from
refactor/sec-bound-key-pin
Open

futurebuildai wants to merge 66 commits into
refactor/v1from
refactor/sec-bound-key-pin

Conversation

@futurebuildai

@futurebuildai futurebuildai commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

What

A branch bound machine key never acts outside its branch, denied by default. ADR 0007 section 5.5 pins a bound key to its branch; the opt in fix that walled each route the sweep found failed open on every route a round missed (rounds 3 and 4 kept finding siblings: the index refresh preview, the customer scoped pricing and tax reads, a pricing rule naming a branch B customer's job, the dealer wide pricing writes, the GL, AP and bankrecon writes). This round replaces it: one allowlist (core/pkg/middleware/keybranch_allow.go) classifies every route a machine key can reach (345 census routes, method plus pattern), and one runtime check, KeyBranchRouteGate mounted once in serve's chain inside auth, refuses a bound key every route the table does not admit, a route the router resolves to no pattern included. A census test in the untagged suite fails when a machine key reachable census route is unclassified or a table entry is stale. A user and an unbound key are unchanged everywhere, whatever the multi_branch_enabled switch says.

The sweep

Every route a machine key can reach that does not run behind the branch middleware, and writes (or reads another branch's rows):

# Module Route Scope that opens it Names a branch or branch scoped row Escape
1 internal/location PUT /api/v1/locations/{id} locations:write path id is a location of a branch yes: update by id with no guard
2 internal/location DELETE /api/v1/locations/{id} locations:write path id as above yes: archive by id with no guard
3 internal/location POST /api/v1/branches branches:write creates a branch (the directory) yes: a new branch is outside every pin
4 internal/location PUT /api/v1/branches/{id} branches:write path id is the branch yes for another branch
5 internal/location DELETE /api/v1/branches/{id} branches:write path id is the branch yes for another branch
6 internal/location POST /api/v1/users/{sub}/branches users:grants body branch_id yes: grants any branch
7 internal/location DELETE /api/v1/users/{sub}/branches/{branch_id} users:grants path branch_id yes: revokes any branch's grant
8 internal/location PUT /api/v1/users/{sub}/home-branch users:grants body branch_id yes: moves home to any branch
9 internal/pricing (wire_exposure) POST /api/v1/quotes/{id}/exposure/acknowledge quotes:write path id is a quote yes under AUTH_MODE=dev (the actor fallback passes a keyless caller as the dev owner); under a JWKS the no user actor requirement already answered 401
10 internal/pricing (wire_exposure) POST /api/v1/quotes/{id}/exposure/request-ack quotes:write path id as above yes under AUTH_MODE=dev, the write is unconditional once the actor resolves
11 internal/pricing (wire_exposure) POST /api/v1/quotes/{id}/exposure/override quotes:write path id as above yes under AUTH_MODE=dev (the fallback role is owner)
12 internal/pricing (wire_exposure) POST /api/v1/admin/exposure-scan admin:write none by name: re checks every branch's quotes yes: dealer wide, cannot be confined
13 internal/pricing (wire_exposure) POST /api/v1/quotes/{id}/exposure/escalate-now quotes:write path id as above read only compute, but discloses another branch's quote exposure, so refused with the same wall
R1 internal/location GET /api/v1/branches/{id}/users users:read the named branch's user_locations rows read exposure: narrowed (refused for another branch)
R2 internal/location GET /api/v1/users/{sub}/branches users:read the user's grants across every branch read exposure: narrowed (filtered to the pin for a bound key)
R3 internal/pricing GET /api/v1/quotes/{id}/exposure quotes:read the named quote's exposure state and events read exposure: narrowed (the same row wall as the writes)
L1 internal/location GET /api/v1/branches, GET /api/v1/branches/{id} branches:read every branch row listed for the lead: PR 39 recorded the branch directory as unwalled reference data
L2 internal/pricing GET /api/v1/quotes/exposure, GET /api/v1/reports/exposure quotes:read, reports:read cross branch lists listed for the lead: narrowing needs a branch filter arm in the exposure repository
L3 internal/events GET /api/v1/events events:read every branch's outbox events listed for the lead: the feed is a cursor tail over the whole outbox
L4 internal/gl, ap, bankrecon, salesteam the report and ledger reads gl:read, ap:read, bankrecon:read, sales-team:read dealer wide aggregates listed for the lead: those modules carry no branch dimension (migration 064 defers the GL's)

Writes with no branch fact (no escape, unchanged): units, parsing upload, pricing categories, category rules, rebates, market indices, vendors, gl, edi, ap, bankrecon, reporting, techadmin settings, staff and module switches, apps, vision, millwork, configurator, governance. Not reachable by a machine key at all: /api/portal/*, /api/integration/*, /api/partner/* (the partner segment is not in the scope vocabulary), /api/v1/a2a, /api/v1/admin/keys and /api/v1/me (user only). Flagged for the lead and not fixed here: the tax exemption routes name a customer whose branch set is many valued (customer_branches), so refusing needs a policy for a customer that spans the pin's branch and another.

Each refusal with its test

Every refused route is driven through serve's real mount methods (branchWall.locations, registerExposureRoutes) behind the real machine key core with a really minted key, in core/internal/app/serve/wire_key_branch_pin_test.go. Each matrix is: bound to A against B (403 forbidden, one fresh key.branch_refused audit row, no write), against A (allowed), unbound key and user (unchanged). All four tests were red first at 1a719fd (22 assertion failures, every one an escape, including a real cross branch exposure event write).

  • TestKeyBranchPin_LocationWrites: PUT and DELETE /locations/{id}, POST /branches, PUT and DELETE /branches/{id}.
  • TestKeyBranchPin_UserGrants: the grant, revoke and home branch routes, by body and by path.
  • TestKeyBranchPin_LocationReads: GET /branches/{id}/users refused for another branch; GET /users/{sub}/branches narrowed to the pin for a bound key.
  • TestKeyBranchPin_ExposureRoutes: the five by id exposure routes (foreign quote refused, own quote passes to the module: 200, 200, 200 with the event written, 409, 409), the scan refused for a bound key, unbound key and user unchanged, and the auth core's own X-Branch-Id rule standing in front.

Docs

CONTRACT-CHANGES gains the SEC-BOUND-KEY-PIN row stating exactly what changed. ADR 0007 section 5.5 gains one paragraph saying how a route without the branch middleware is held to the pin and why the directory create and the dealer wide routes refuse a bound key outright. docs/modules/drafts.md and docs/modules/locations.md bound key paragraphs now describe the fixed behaviour, with the branch directory reads, the exposure lists and the events feed named as stated limits.

🤖 Generated with Claude Code

Fix round 2: the allowlist (deny by default)

Class counts over the 345 machine key reachable census routes after fix
round 3 (the round 2 counts were WALLED 183, CONFINED 28, STATED LIMIT 69,
REFUSED 65):

  • WALLED: 145 (behind the branch middleware; each entry's note names the column or join its module filters on, and a twin sweep drives every id or list route with the other branch's row)
  • CONFINED: 31 (the key branch wall or the customer branch wall holds the request to the pin; each entry's test is named in the table)
  • STATED LIMIT: 51 (reads reachable unconfined by design, each entry with its one line reason, listed in ADR 0007 section 5.5; a stated limit is a read, never a write)
  • REFUSED: 118 (403 forbidden with the key.branch_refused audit row)

Every CONFINED entry:

  • DELETE /api/v1/branches/{id}
  • DELETE /api/v1/locations/{id}
  • DELETE /api/v1/pricing/category-rules/bulk
  • DELETE /api/v1/pricing/category-rules/{id}
  • DELETE /api/v1/tax/exemptions/{id}
  • DELETE /api/v1/users/{sub}/branches/{branch_id}
  • GET /api/v1/branches/{id}/users
  • GET /api/v1/pricing/calculate
  • GET /api/v1/pricing/category-rules
  • GET /api/v1/pricing/category-rules/{id}/audit
  • GET /api/v1/pricing/resolve
  • GET /api/v1/quotes/{id}/exposure
  • GET /api/v1/tax/exemptions/{customerID}
  • GET /api/v1/users/{sub}/branches
  • POST /api/v1/pricing/category-rules
  • POST /api/v1/pricing/category-rules/bulk
  • POST /api/v1/pricing/rules
  • POST /api/v1/quotes/{id}/exposure/acknowledge
  • POST /api/v1/quotes/{id}/exposure/escalate-now
  • POST /api/v1/quotes/{id}/exposure/override
  • POST /api/v1/quotes/{id}/exposure/request-ack
  • POST /api/v1/tax/exemptions
  • POST /api/v1/tax/preview
  • POST /api/v1/users/{sub}/branches
  • PUT /api/v1/branches/{id}
  • PUT /api/v1/locations/{id}
  • PUT /api/v1/pricing/category-rules/{id}
  • PUT /api/v1/users/{sub}/home-branch

Every STATED LIMIT entry:

  • DELETE /api/v1/admin/settings/ai
  • DELETE /api/v1/admin/settings/routing
  • DELETE /api/v1/delivery/drivers/{id}
  • DELETE /api/v1/delivery/vehicles/{id}
  • DELETE /api/v1/edi/partners/{id}
  • GET /api/v1/admin/modules
  • GET /api/v1/admin/settings/ai
  • GET /api/v1/admin/settings/routing
  • GET /api/v1/apps
  • GET /api/v1/branches
  • GET /api/v1/branches/{id}
  • GET /api/v1/configurator/options
  • GET /api/v1/configurator/presets
  • GET /api/v1/configurator/rules
  • GET /api/v1/delivery/drivers
  • GET /api/v1/delivery/drivers/{id}
  • GET /api/v1/delivery/vehicles
  • GET /api/v1/delivery/vehicles/{id}
  • GET /api/v1/edi/partners
  • GET /api/v1/edi/partners/{id}
  • GET /api/v1/edi/partners/{id}/catalog
  • GET /api/v1/governance/rfcs
  • GET /api/v1/governance/rfcs/{id}
  • GET /api/v1/market-indices
  • GET /api/v1/market-indices/{id}/history
  • GET /api/v1/matching/config
  • GET /api/v1/millwork/options
  • GET /api/v1/millwork/options/{id}
  • GET /api/v1/price_levels
  • GET /api/v1/pricing/categories
  • GET /api/v1/pricing/rebates/programs
  • GET /api/v1/pricing/rebates/programs/{id}
  • GET /api/v1/pricing/rebates/programs/{id}/claims
  • GET /api/v1/units
  • GET /api/v1/units/{code}
  • GET /api/v1/vendors
  • GET /api/v1/vendors/{id}
  • POST /api/v1/apps/{key}/disable
  • POST /api/v1/apps/{key}/enable
  • POST /api/v1/configurator/build-sku
  • POST /api/v1/configurator/validate
  • POST /api/v1/delivery/drivers
  • POST /api/v1/delivery/drivers/{id}/photo
  • POST /api/v1/delivery/vehicles
  • POST /api/v1/delivery/vehicles/{id}/photo
  • POST /api/v1/edi/partners
  • POST /api/v1/edi/partners/{id}/import-catalog
  • POST /api/v1/governance/rfcs
  • POST /api/v1/governance/rfcs/{id}/transitions
  • POST /api/v1/millwork/options
  • POST /api/v1/parsing/upload
  • POST /api/v1/pricing/calculate-escalation
  • POST /api/v1/pricing/categories
  • POST /api/v1/pricing/rebates/programs
  • POST /api/v1/pricing/rebates/programs/{id}/claims/calculate
  • POST /api/v1/units
  • POST /api/v1/vendors
  • POST /api/v1/vision/scan
  • PUT /api/v1/admin/modules/{id}
  • PUT /api/v1/admin/settings/ai
  • PUT /api/v1/admin/settings/routing
  • PUT /api/v1/delivery/drivers/{id}
  • PUT /api/v1/delivery/vehicles/{id}
  • PUT /api/v1/edi/partners/{id}
  • PUT /api/v1/governance/rfcs/{id}
  • PUT /api/v1/market-indices/{id}
  • PUT /api/v1/matching/config
  • PUT /api/v1/pricing/categories/{id}
  • PUT /api/v1/units/{code}

The wall dispatchers now match r.Pattern (the registered pattern), not path suffixes; job_id resolves to its projects row's customer beside customer_id (both checked); the customer scoped reads that name a customer in a path, query or body are confined like the writes; and a write that would be dealer wide (a rule naming neither a customer nor a job, a tier category rule, a bulk delete naming a tier row) is refused. The GL, AP and bankrecon surfaces, the staff roster, the index refresh preview and the unfiltered pricing lists are REFUSED, reads and writes alike. ADR 0007 section 5.5 names the stated limits and advises which scopes not to mint on a bound key.

Fix round 3 (rounds 5 and 6, the principal's rulings)

Every changed entry, its old class in brackets:

  • REFUSED [was WALLED] POST /pos/till/{id}/close, GET /pos/till/{id}/report, GET /pos/till/{id}/zreport, GET /pos/zreports, GET /pos/till/current now hold till_sessions.branch_id / till_z_reports.branch_id in the repository (WALLED with its column named); GET /pos/returns, GET /pos/returns/{id} hold pos_returns.branch_id; POST /pos/transactions/{id}/items and DELETE .../items/{itemId} hold the transaction's branch with the insert never happening on a foreign transaction.
  • CONFINED [was WALLED] GET /accounts/{id}, GET /accounts/{id}/transactions, GET /ar/customers/{id}/statement (the customer branch wall on the path customer); REFUSED [was WALLED] GET /ar/reconciliation (its ledger half carries no branch fact).
  • CONFINED GET /pricing/calculate now also resolves job_id to projects.customer_id (the pin's customer with the foreign customer's job is refused); POST /tax/preview is confined through a named ship_to_id's customer.
  • The category rule invariant (round 6 P1-1): the wall refuses any element whose target is not an account rule or that carries a tier, however many branches of a named customer hold the pin; the handler 400s the pair; migration 107 adds the check constraint (an account rule tierless, a tier rule customerless), reporting existing rows that break it by NOTICE with count and ids, never rewriting, the constraint NOT VALID when one exists and validated on add otherwise.
  • REFUSED [was STATED LIMIT] every stated limit write (the principal's item 37): PUT/DELETE /admin/settings/ai, PUT/DELETE /admin/settings/routing, PUT /admin/modules/{id}, POST /apps/{key}/enable, POST /apps/{key}/disable, PUT /matching/config, POST/PUT/DELETE and POST .../import-catalog on /edi/partners, POST /vendors, POST/PUT /units, POST/PUT /pricing/categories, POST /pricing/rebates/programs, POST /millwork/options, POST/PUT and POST .../transitions on /governance/rfcs, PUT /market-indices/{id}, the vehicle writes; the reads stay stated limits.
  • REFUSED [was STATED LIMIT] the driver routes, reads included (PD-3: driver rows carry personal data and no branch column); the vehicle reads stay stated limits.
  • REFUSED [was WALLED] the 18 dealer wide catalogue writes (item 38): POST /products, PATCH /products/{id}/margins, /dimensions, /lead-time, PUT /products/{id}/kit-components, PUT /products/{id}/units, PUT /products/{id}/pim/content, the two PIM deletes, PATCH .../pim/media/{mediaId}/primary, the four POST .../pim/generate/*, POST/PUT /charge-codes, POST/PUT /payment-terms, and POST /purchase-orders/refresh-reorder-targets; their reads are STATED LIMIT [was WALLED] (GET /products and its detail, kit components, PIM reads and unit sets, GET /charge-codes, GET /payment-terms, the product links, the POS product search and catalog).
  • The stricter shared customer line (PD-4): a bound key may read a customer shared with another branch, but POST /pricing/rules, the category rule writes (single, bulk, update, delete, bulk delete) and the tax exemption writes on it are refused unless the pin holds the customer alone, the refusal naming the other branch; the reads keep the shared rule.
  • PD-5: the grant, revoke and home branch writes for the pin branch record user.branch.granted, user.branch.revoked and user.home_branch.set audit rows with the key as the actor. PD-6: the parsing upload and the vision scan stay stated limits and each call records its ai.parsing.upload / ai.vision.scan audit row with the key that spent the credit.
  • Round 5 P3s: the gate's nil resolver arm is pinned by a test (red under mutant G5), the twenty REFUSED routes no independent test named joined DealerWideReads, and PUT /locations/{id} writing path is probed and pinned: the path is a materialized breadcrumb with no enforcement, the branch comes from the parent at insert, and no read resolves a branch from a path, so a path naming the foreign branch cannot move a row under it.

ADR 0007 section 5.5, the CONTRACT-CHANGES row SEC-BOUND-KEY-PIN-3 and the delivery, charge codes, products and locations module pages say the same. The pos handler file is untouched (PR 72 rewrites it): the pos confinement lives in the repository queries and the branch wall layer; the pos files changed are internal/pos/till_repository.go, internal/pos/returns_repository.go, internal/pos/repository.go and internal/pos/service.go.

Fix round 4 (head 7116cc5)

Class counts are unchanged over the 345 machine key reachable census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118. No route was added or removed. No pos file changed in this round (till_repository.go, returns_repository.go, repository.go and service.go under internal/pos are untouched). refactor/v1 (3c178d7) merged first with no conflicts.

Changed entries and walls added:

Route Wall added or changed
POST /payments, POST /payments/card, POST /payments/intent key branch wall on the body customer_id (wall.payments); intent names no customer and passes
POST /credit-memos, PUT /credit-memos/{id} same, wall.invoices
POST /orders, PUT /orders/{id} same, wall.orders; and for every caller the ship to and contact must belong to the order's customer (400 naming the field)
POST /quotes, PUT /quotes/{id} same, wall.quotes
POST /drafts/quotes/{id}/promote, POST /drafts/orders/{id}/promote customer read from the draft's stored payload (wall.draftGuard)
GET /orders/{id}/exposure-gate, POST /orders/{id}/exposure-override the order is read under the branch wall before its source quote is looked up (found by the new sweep; a key bound to A could read and clear B's quote exposure)
DELETE /pricing/category-rules/{id}, DELETE /pricing/category-rules/bulk any row whose target is not an account rule is dealer wide, customer or not
GET /pricing/category-rules/{id}/audit plain customer wall (a shared customer's rule audit is readable, PD-4)
PUT /customers/{id}, PATCH /customers/{id}/salesperson, PUT /customers/{id}/escalation-policy, PUT /ship-tos/{id}, PUT /contacts/{id}, DELETE /contacts/{id} the shared customer rule below, held in the customer service

Shared customer fields (deny by default)

A field not listed as BRANCH LOCAL is RESTRICTED. A bound key on a customer held by its branch and another is refused on every RESTRICTED field (403, the PD-4 words naming the lowest numbered other branch, a key.branch_refused row, nothing changed); a customer held by the pin alone, a user and an unshared customer are unchanged; an unbound key changes them with a customer.shared_restricted_changed row naming the key and the before and after values.

Route Field Class Reason
PUT /customers/{id} account_number RESTRICTED the identifier every branch's documents carry
name RESTRICTED printed on every branch's documents
email BRANCH LOCAL contact channel
phone BRANCH LOCAL contact channel
address RESTRICTED billing address on every branch's invoices
tier RESTRICTED prices every branch's sales
is_active RESTRICTED deactivates the customer everywhere
price_level_id RESTRICTED prices every branch's sales
salesperson_id RESTRICTED one salesperson across branches, not plainly branch local
credit_limit_cents RESTRICTED credit for every branch's orders
currency RESTRICTED currency of every branch's documents
payment_terms_id RESTRICTED due dates and collections
po_required RESTRICTED purchase order rule on every branch's orders
primary_branch_id not changeable 400 for every caller
PATCH /customers/{id}/salesperson salesperson_id RESTRICTED as above
PUT /customers/{id}/escalation-policy policy, threshold_percent, agreement_signed_at, agreement_ref RESTRICTED governs repricing of every branch's quotes
POST /customers/{id}/ship-tos, /contacts a new record allowed adds only
PUT /ship-tos/{id}, PUT /contacts/{id}, DELETE /contacts/{id} an existing record shared refused on a shared customer (no branch column, so all are shared); allowed where the pin holds the customer alone

No other route writes a customer's record (the repository writes are create, update, salesperson and escalation policy; the AR balance posting runs through the walled payment and invoice routes).

Tests

Each fix was red first on 372b37a (the legs answered 2xx or 204 and wrote rows) and is green now; each is also proven by a mutant (about 30 applied one at a time, all red). The twin sweep sends valid bodies with an own twin control, measures by the twins' ids, and a coverage test fails by name for any WALLED route without a bound key leg. Migration 107 has a test. Open for the lead: a machine key carries no role, so the ruling's "finance or owner" condition for the unbound key cannot be evaluated; the unbound key is allowed and audited.

Fix round 5 (reviews 10 and 11, principal ruling item 48)

Class counts are unchanged over the 345 machine key reachable census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118. No route was added or removed. refactor/v1 (d9ecb2b) was already the merge base: the round opened already up to date, no conflicts. PR 72 has not merged: its resolve route is not in the allowlist and its branch is not merged.

Every route and wall added or changed this round:

Route Wall or rule added
POST /pos/sync key branch wall over the body's items[].customer_id (wall.pos, the resolver posSyncCustomersOf): a replayed sale on a customer the pin does not hold is 403 naming customer_id, nothing written (its ACCOUNT tender would invoice that customer); the change is in the wall layer and the allowlist note only, no pos code touched
POST /orders, PUT /orders/{id}, POST /quotes, PUT /quotes/{id} the body's job_id is held through projects.customer_id beside customer_id (bodyDocumentCustomer over documentCustomersOf, the resolution the pricing calculate read carries)
POST /quotes/{id}/convert the quote's stored customer is rechecked under the customer wall (quoteRowCustomerOf), as the draft promotions recheck their payload: a quote a caller moved onto a foreign customer is refused where it would become an order
the order and quote builds (create, update, the conversion) for every caller, a job_id whose project belongs to a different customer than the document's is refused 400 naming job_id ("is not a job of this customer", the credit memo's words); the order's OwnedBy carries the job beside the ship to and the contact, and the quote's priceDraft checks it through JobOwnedBy
POST /customers/{customerId}/activities, PUT /activities/{id} for every caller, a contact_id the activity's customer does not own is refused 400 naming contact_id ("is not a contact of this customer", ContactBelongsTo)
POST /customers/{id}/ship-tos with is_default true refused on a shared customer like a change (checkSharedChild naming is_default): naming a default clears the existing default, a shared ship to every branch's delivery orders default to; an add naming no default stays allowed, and the customer the pin holds alone is unchanged
POST /payments/intent leaves the body customer wall: the route takes no customer, so its own 400 unknown field answer serves every caller alike (round 4's body said the wall held it and it "passed", which misled: it refused a bound key for a field the route ignores)
PUT /pricing/category-rules/{id} an update of a legacy row that breaks migration 107's single target invariant answers 409 naming the invariant for every caller, where the NOT VALID constraint's revalidation on the write used to surface as a 500

The customers:restricted scope (item 48)

A shared customer's RESTRICTED fields change only with the finance or owner role (a user) or an unbound machine key carrying the dedicated customers:restricted scope. The scope is in the grant grammar (ValidScopeGrammar) though no route requires it, so no module scope and no default grant carries it; a mint naming no scopes grants none. Only an owner may mint it: the admin role gets a 403 naming the scope, and a caller with no claims fails closed; the mint's key.created audit row names every granted scope, that one included. A sales user, an admin user and every unbound key without the scope are refused in the PD-4 words naming the lowest numbered branch holding the customer; a bound key is refused even with the scope. The customer.shared_restricted_changed audit row stays exactly as it was, written by the scoped key's change with the actor and the before and after values; a user's change writes no row. The customer routes' user guard admits finance beside admin, owner and sales so the ruling's role can reach the fields. The escalation audit's before and after values now carry agreement_signed_at beside the reference.

Shared customer fields (deny by default, round 5 state)

The field classes are unchanged from round 4 (the table below stands); what changed is who may change a RESTRICTED field of a shared customer (the scope rules above) and one new row:

Route Field Class Reason
POST /customers/{id}/ship-tos with is_default true the existing default shared record the add clears the shared default ship to, so it is refused like a change; an add naming no default is allowed

Tests

Each code fix was red first on 4bad569 and is green now (the pos sync leg showed the sale synced and the foreign invoice written; the job legs showed the foreign job stored and priced by its override; the convert leg showed the order created with the foreign exemption; the default add leg showed the shared default cleared; the activity legs showed the foreign contact written; the scope legs showed the mint refused by the grammar and every caller change open; the legacy row's PUT showed the 500). Each test gap is proven by a mutant: the credit memo PUT pattern dropped from the invoices mount, salesperson_id classed BRANCH LOCAL, a DESC on either also held by lookup (the shared customer now carries a third branch), RecordRequest removed from wall.customers, policyFacts without the signature date, and a route driven only as an A control failing the coverage by name (the walled route coverage now counts only the drivers' explicit twin marks). Left for a later item: the pre existing draft order promote 500 (round 11 P3-3, identical on the base). The exposure gate and override fix and its coverage test are kept (item 48).

Fix round 6

Review 13 (second review) and the principal's item 54 rulings. No route added or removed (the same 345 census routes).

  • A register's branch is COALESCE(l.branch_id, r.branch_id) in the transaction create, the till open and the return lookup, so a bound key's pos sync on a register whose location names no branch is held to the pin (it wrote an open transaction at the default branch). The till session now carries the register's branch (branch_id in its body, four pos goldens re-recorded, listed in CONTRACT-CHANGES); the seed moves REG-01 onto Kelowna so seeded amounts are unchanged.
  • A user or an unbound key refused on a shared customer's restricted fields now gets the bound key's words and a customer.shared_restricted_refused audit row (actor, fields, branch, method, path).
  • Finance reaches only the header write, the salesperson and the escalation policy of a customer; every other customer route is back to admin, owner, sales.
  • On a shared customer a bound key adds an activity and never changes or deletes one (the contact rule: same words, key.branch_refused row).
  • New tests for the stored job recheck on the quote conversion, the claims-less mint refusal, and a bound key that truly carries customers:restricted.
  • ADR 0007 section 5.5, the allowlist notes, CONTRACT-CHANGES SEC-BOUND-KEY-PIN-6 and the customers and CRM activities pages state every changed rule.

The sweep behind this test (PR body) found every route a machine key
reaches that writes or reads another branch's rows without the branch
middleware: the location module's unwalled writes (PUT and DELETE
/locations/{id}, POST /branches, PUT and DELETE /branches/{id}, the
user grant, revoke and home branch routes), the exposure surface's by
id routes on quotes, and the unwalled branch reads. ADR 0007 section
5.5 pins a bound key to its branch; only the branch middleware and the
payload guard honoured the pin, and these routes mount neither.

The tests drive serve's real mounts (branchWall.locations,
registerExposureRoutes) behind the real machine key core with a really
minted bound key, an unbound key and a user. All four fail at 1a719fd
on exactly the escapes: the bound key edits, archives, grants, revokes
and reads branch B's rows, writes an exposure event on branch B's
quote, and runs the dealer wide exposure scan.

Signed-off-by: colton <colton@futurebuild.ai>
…rywhere

ADR 0007 section 5.5 pins a branch bound machine key to its branch, but
only the branch middleware honoured the pin, and a machine key passes
the role guards on its scope alone, so every route that mounted no
branch middleware let a bound key act on any branch its body or path
named: the location module's by id writes, the branch directory verbs,
the user grant, revoke and home branch routes, the exposure surface's
by id quote routes (whose actor fallback admits a key under
AUTH_MODE=dev), and the dealer wide exposure scan.

The wall (pkg/middleware/keybranch.go, beside the branch guard) reads
the pin through branchctx.KeyBranchFromContext and compares the branch
the request names by a path value, a body branch_id, or a row lookup
(locations through their tree, quotes through their branch): a bound
key naming another branch is refused 403 forbidden in the ADR envelope
naming the field, with the key.branch_refused audit row, the same
verdict the X-Branch-Id rule writes; its own branch passes, and a user
and an unbound key pass unchanged, whatever the multi_branch_enabled
switch says. The directory create and the scan refuse a bound key
outright, being outside every pin. The branch users read of another
branch is refused the same way, and a user's branch list is narrowed
to the pin for a bound key.

The wall mounts at the route registrations: the location handler takes
it through WithKeyBranchWall (serve's branchWall builds it with the
audit logger), and registerExposureRoutes composes it onto the
exposure handler's guard. The two exposure lists and the branch
directory reads stay un-narrowed: narrowing them changes recorded
contracts (the exposure repository, PR 39's reference data decision)
and the sweep lists them for the lead.

Signed-off-by: colton <colton@futurebuild.ai>
CONTRACT-CHANGES gains the SEC-BOUND-KEY-PIN row stating exactly what
changed on every route the key branch wall now covers. ADR 0007 5.5
gains the sentence that says how a route without the branch middleware
is held to the pin and why the directory create and the dealer wide
routes refuse a bound key outright. The drafts and locations module
docs' bound key paragraphs now describe the fixed behaviour: the gap
text (an operator should not mint a branch bound key with
users:grants, branches:write or locations:write) is gone, replaced by
what each route answers, with the branch directory reads, the exposure
lists and the events feed named as the stated limits the sweep listed
for the lead.

Signed-off-by: colton <colton@futurebuild.ai>
The promotion test flips multi_branch_enabled globally with no lock,
and a package whose fixture reads the setting mid flip (CI: the
invoice credit memo test's sales caller) caches the flipped state for
its middleware's 30 second TTL and answers 400 X-Branch-Id header
required where its role guard owns the 403. Every other settings
flipper (the serve wall and key pin fixtures) already rides the outbox
lock; this one now does too.

Signed-off-by: colton <colton@futurebuild.ai>
…ound-key-pin

Signed-off-by: colton <colton@futurebuild.ai>
…er, red first

The second review proved the wall and the handler read the body differently:
the wall's json.Unmarshal fails on a body with trailing data and so names no
branch, while the grant and home branch handlers' json.Decoder reads the
first value and ignores the rest, so a key bound to A wrote a grant for B and
moved a home branch to B with no audit row. Five bodies from the review, red
at 7ef2d1d: all five land the write with 204.

Signed-off-by: colton <colton@futurebuild.ai>
BodyBranch parsed with json.Unmarshal and let an unparsable body through as
naming no branch, while the grant and home branch handlers decode with
json.Decoder and read only the first value: a valid object followed by
trailing data passed the wall and the handler acted on the first value. The
wall now decodes into the same branch_id uuid.UUID the handlers decode, and a
body that is not exactly one complete JSON value is refused for a bound key
with the same 403 and key.branch_refused row as a foreign branch, so the wall
and the handler can never disagree about what a body names. A body that
parses and names no branch (absent, null) still passes to the handler's own
400, and the explicit nil UUID now meets the handler's branch_id required
check rather than a wall refusal. Users and unbound keys never reach the
parse.

Signed-off-by: colton <colton@futurebuild.ai>
The sweep the reviews ran past the wall: the reporting module in all three
of its registrations, the events feed, the two exposure lists, the GL, AP and
bank reconciliation reads, the sales team reads, the known users list and the
market index refresh are all open to a branch bound key today (30 routes,
each one landing its module answer instead of the 403), while the unbound
key and the user already answer the same as each other on every route.

Signed-off-by: colton <colton@futurebuild.ai>
The lead's ruling on the reads and writes the sweep missed (TO-PRINCIPAL 51):
every reporting route in its three registrations, the events feed, the two
exposure lists, the GL, AP and bank reconciliation reads, the sales team
reads, the known users list and the market index refresh now refuse a branch
bound key outright with the key.branch_refused row. The GL, AP and bank
reconciliation writes carry no branch fact and keep the role guard alone for
every principal, as does the index metadata and history beside the refresh.
Users and unbound keys keep exactly their reach: the test walks all thirty
routes with the unbound key and the user and they answer the same as each
other, 200 on the reads that answer data.

Signed-off-by: colton <colton@futurebuild.ai>
The lead's ruling on the customer scoped writes: the tax exemption writes and
the customer priced rules (plain and category, single and bulk) are open to a
branch bound key today, so a key pinned to A writes branch B's customers'
data: eight write families land their module answer instead of the 403, with
the rows written. The unbound key and the user answer the same as each other
on every route, each against its own fresh target.

Signed-off-by: colton <colton@futurebuild.ai>
…the customer's branches

The lead's ruling on the customer scoped writes: the tax exemption writes and
the customer priced rules (plain and category, single and bulk) now reach a
branch bound key only while every customer the request names, or that the row
it acts on belongs to, holds the pin among the customer's branches
(customer_branches), the same visibility the customer module holds a caller's
branch context to; anything else is the 403 refusal with its
key.branch_refused row, the details naming customer_id. A request that names
no customer passes (it writes dealer wide data), a body that is not exactly
one complete JSON value is refused like the body branch wall refuses it, and
a lookup failure answers 500 rather than failing open. The reads keep the
role guard alone; users and unbound keys never reach the wall.

Signed-off-by: colton <colton@futurebuild.ai>
…ncluded

pkg/middleware had no test file for keybranch.go at all, and the first
review's mutant M6 (a branch lookup error fails open) survived every test.
The unit tests walk every constructor with a recording auditor and no
database: a pin and a foreign branch by path, body (whole, or refused), row
and customer, the unparsable and trailing data bodies, the malformed and
missing ids that pass to the handler's own answers, the no pin pass through,
and the failure paths that must never reach the handler: a lookup error and
a nil database answer 500. Applied alone, M6 now dies on the lookup error
case (the handler ran, want the wall to fail closed).

Signed-off-by: colton <colton@futurebuild.ai>
The second review seeded a legacy location row with a NULL branch (user
triggers lifted) and the wall passed it: the bound key PUT and DELETE answered
200 and 204. ErrRowNamesNoBranch now marks a row that exists but belongs to
no branch, and RowBranch refuses a bound key it like a foreign branch, red
first. The switch off case pins the wall's independence from the
multi_branch_enabled switch: with the switch off the pin still refuses the
foreign branch and passes its own, and the unbound key and the user keep
their reach.

Signed-off-by: colton <colton@futurebuild.ai>
ADR 0007 section 5.5 now states the wall's whole final behaviour: the whole
body read, the outright refusals (the directory create, the exposure scan and
lists, the market index refresh, the events feed, every reporting route, the
dealer wide GL, AP and bank reconciliation reads, the sales team reads, the
known users list), the customer_branches confinement of the customer scoped
writes, and the branch directory reads limit in one sentence with the PR 39
reference data reason. drafts.md and locations.md say the same for their
routes, locations.md adds the home to own branch side effect on the user's
other grant rows, the CONTRACT-CHANGES row lists every route and the 403 for
a missing branch id where the base answers 404, and its stray trailing blank
line is gone.

Signed-off-by: colton <colton@futurebuild.ai>
…ed first

The pin fixture now drives the production chain (buildChain) instead of a
hand built machine key mount, mounts the staff roster, seeds a job per
customer, a tier category rule and a certified tax exemption, and the
round 4 findings become assertions: the refresh preview, the GL, AP and
bank reconciliation writes, the staff roster, the unfiltered pricing
lists and the matrix refuse a bound key; the customer scoped reads (the
exemption read by path customer, the category rule list by query
customer, the rule audit, calculate, resolve, the tax preview by body
customer) are confined; a pricing rule naming a job is confined through
the job's customer; a rule naming neither a customer nor a job, and a
tier category rule on every verb, are refused; the stated limits (the
branch directory reads, the index metadata and history) stay open; and
a path no route resolves is refused. The GL writes block that held them
open is gone: the ruling changed. Four of the five new tests fail on
the base head, the stated limits test pins the open behaviour.

Signed-off-by: colton <colton@futurebuild.ai>
One table, core/pkg/middleware/keybranch_allow.go: every route a machine
key can reach (345 of them, method plus pattern exactly as the census
names them) carries exactly one class. WALLED (183) runs behind the
branch middleware; CONFINED (28) is held by the key branch wall or the
customer branch wall, each entry naming its test; STATED LIMIT (69) is
reachable unconfined by design, each entry with its one line reason;
REFUSED (65) answers a bound key 403 forbidden with the
key.branch_refused row.

The check runs once: KeyBranchRouteGate, mounted in buildChain inside
auth (it reads the pin the machine key core sets) and outside the
confirm gate and idempotency (a refused request claims nothing). It
resolves the request's registered pattern through the serve mux and
refuses a bound key every route the table does not admit, including a
route the router resolves to no pattern: a missed mount can no longer
fail open, whatever the wiring beneath the chain does. A request with
no pin passes untouched.

The per mount refusals the opt in approach added are gone (the gate is
the one place refusals live): the reporting, events feed, GL, AP, bank
reconciliation and sales team mounts return to their role guards, the
index admin guard loses its suffix matching (which missed the refresh
preview), the exposure wall keeps only its by id confinement, and the
location handler drops the directory create and known users walls.
RefuseBound leaves the wall's API with its test.

The census test fails when a machine key reachable census route is
unclassified, when an entry matches no census route, and when a
confined or stated limit entry carries no justification; it runs in
the untagged suite with no database. TestKeyBranchPin_AllowlistGate-
CoversCensus drives every reachable census route through the
production chain with stub handlers: the bound key is refused exactly
the 65 REFUSED entries, and the unbound key passes all 345. The pin
fixture now builds its chain with buildChain, the builder Run uses.

Signed-off-by: colton <colton@futurebuild.ai>
The customer branch wall now resolves every field that names a customer
scoped row, not only customer_id:

- a pricing rule's job_id resolves to its projects row's customer, and
  a body naming a customer and a foreign job is refused (both are
  checked), closing round 4 P1: a bound key can no longer set the price
  a branch B customer pays on a branch B job with no audit row;
- a rule that names neither a customer nor a job is a dealer wide write
  and is refused (round 4 P2), as is a category rule or bulk element
  that names no customer (a tier target prices every branch), through
  CustomerBodyBranchRequired, which refuses an object or element with
  no customer_id;
- the customer scoped reads are confined like the writes (round 3
  P1-2, round 4 P2): the exemption read by its path customer, the
  category rule list by its query customer (absent is the unfiltered
  list and is refused), the rule audit by its row's customer, calculate
  and resolve by their query customer, and the tax preview by its body
  customer when it names one. A category row that targets a tier names
  no customer and is refused on every verb, and a bulk delete naming a
  tier row is refused whole.

The walls dispatch on r.Pattern, the registered pattern the mux sets,
not on path suffixes or prefixes.

Signed-off-by: colton <colton@futurebuild.ai>
Round 3 P3-1, both arms now pinned: the location row lookup's SQL error
arm fails closed (a cancelled query answers 500 with the handler never
reached; the mutant that swallows the error into no branch named makes
the test fail), and a bulk category delete body that is not exactly one
complete JSON value is refused for a bound key (the decode error mutant
makes the test fail). Both mutants were green on the previous head.

Round 3 P2-2, the source half: the behaviour tests drive the fixture's
mounts, so Run() dropping a confinement call would leave them green
while production lost its wall. TestServeWiringMountsTheConfinements
pins serve.go's five confinement call sites and chain.go's gate mount;
the behaviour half (the fixture builds its chain with buildChain, and
TestKeyBranchPin_AllowlistGateCoversCensus drives the gate over every
reachable census route) makes a dropped gate fail red on its own.

Signed-off-by: colton <colton@futurebuild.ai>
ADR 0007 section 5.5 now states the deny by default rule itself: the
four classes, the one gate, the census test, the completed confinement
contract (jobs resolve to their projects row customer, the customer
scoped reads are confined, dealer wide writes are refused), the stated
limits each with their reason, the do not mint advice round 3 P3-2
asked for, and the refused surfaces (the GL, AP and bank
reconciliation writes included). The SEC-BOUND-KEY-PIN row, the drafts
module page and the locations module page say the same shape.

Signed-off-by: colton <colton@futurebuild.ai>
Signed-off-by: colton <colton@futurebuild.ai>
…ound-key-pin

Signed-off-by: colton <colton@futurebuild.ai>
…e key branch wall

Round 6 P1-1: a tier category rule that also named a customer passed the
customer body wall (it reads customer_id only), was written, and priced
every branch's customers of that tier while the allowlist said tier targets
are refused. The wall's category body resolver now decodes the same target
the handler parses (target_type, tier, customer_id, per element of a bulk
body) and refuses any element whose target is not an account rule or that
carries a tier; the handler keeps the invariant on its own side (a tier rule
carries no customer, an account rule no tier); migration 107 puts the
invariant on the table, checking existing rows first: a row that breaks it
is reported by a NOTICE naming the count and ids, never rewritten, and the
constraint goes on NOT VALID when any exists, validated on add otherwise.

Signed-off-by: colton <colton@futurebuild.ai>
Round 5 P1 and round 6 P1-2: the till, Z report, return and account routes
loaded their row by id with no branch predicate, so a key bound to A closed
B's till (posting its over/short and freezing its Z report), read B's till
report, Z reports, returns, account summary, AR statement and
reconciliation, and a line insert on B's transaction left the line written
behind its 500. The till, Z report and return reads now carry the branch
predicate the transaction reads already carried (till_sessions.branch_id,
till_z_reports.branch_id, pos_returns.branch_id); the line delete joins its
transaction's branch; AddItem reads the transaction (through the same
predicate) before anything is written, so the insert never happens on a
transaction the caller may not target; the account summary, subledger and
statement mount the customer branch wall on the path customer; and the
reconciliation, whose ledger half carries no branch fact, is refused to a
bound key by the route allowlist. The pos handler file is untouched (PR 72
rewrites it): the confinement lives in the repository queries and the
branch wall layer.

Signed-off-by: colton <colton@futurebuild.ai>
…tomer

Round 5 F-3: GET /pricing/calculate confined only its customer_id, so the
pin's customer plus the foreign customer's job answered with the job
override rule that prices the foreign customer, byte for byte the unbound
key's answer. The calculation's resolver now resolves job_id to
projects.customer_id, the same resolution the rule write carries, and holds
both customers to the pin; a job that names no customer changes nothing.

Signed-off-by: colton <colton@futurebuild.ai>
…s customer

Round 6 P3: POST /tax/preview with the foreign branch's ship_to_id and no
customer passed the wall (the resolver read customer_id only) and answered a
rate for a delivery the key's branch does not serve. The preview's resolver
now resolves ship_to_id through customer_ship_tos.customer_id and holds that
customer to the pin beside the body's customer_id; a preview naming neither
stays a plain rate calculation.

Signed-off-by: colton <colton@futurebuild.ai>
…ion path probe

Round 5 P3: the gate fails closed when its mux is not a pattern resolver
(mutant G5 survived every test; the pin drives a plain handler mux and
expects the refusal, and goes red under the mutant); the twenty REFUSED
routes no independent test named (staff id and modules, reporting saved and
schedules, AP invoice read and approve, bankrecon session read, account
create, complete and unmatch, GL reopen, post, reverse, void and account
write) join DealerWideReads by name with their parity legs; and
PUT /locations/{id} writing path is probed and pinned: the path is a
materialized breadcrumb with no enforcement, the branch comes from the
parent at insert (the denorm trigger fires on parent_id and type, neither
writable by the route), and no branch scoped read resolves a branch from a
path, so a path naming the foreign branch's breadcrumb cannot move a row
under it.

Signed-off-by: colton <colton@futurebuild.ai>
…er writes it

The principal's ruling (FROM-PRINCIPAL 37 and 38): every STATED LIMIT write
is refused to a branch bound key, the reads stay stated limits; the dealer
wide catalogue writes the branch middleware cannot confine (products, PIM
content, margins, dimensions, lead time, kit components, unit sets, payment
terms, charge codes, the reorder target refresh) are dealer wide writes and
are refused with their reads stated; and the driver routes are refused
outright, reads included (PD-3: driver rows carry personal data and no
branch column), while the vehicle reads stay stated limits. The class split
in the table: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118 of the
345 machine key reachable routes. One A/B leg per write family drives every
refused family with its audit row beside the unbound key's own module
answer, and the margin write proves the refusal is the only thing that kept
the dealer wide target margin unwritten.

Signed-off-by: colton <colton@futurebuild.ai>
… a twin sweep

The lead's ruling on item 37: WALLED becomes a tested claim. Every WALLED
entry's note now names the column or join its module filters on
(quotes, orders, invoices, payments, credit memos, purchase orders, drafts
and till rows on their branch_id; customers, contacts, ship tos and
activities through customer_branches; deliveries and routes through their
orders' branch; the dashboard and inventory through locations.branch_id;
the links through each module's walled read), replacing the bare the branch
middleware label that rounds 5 and 6 showed can overstate. A new sweep
drives every WALLED route that takes an id or a list with branch B's twin
row as a bound A key's target: the foreign row answers exactly what a
missing row answers (or a 403), never names the row, nothing is written
(family tables counted before and after), a user reads the twin where the
missing row is a 404 so the twins are real, and the lists carry the pin's
branch only. The fixture grows the document family mounts exactly as serve
registers them and a second mint pair for the document scopes (the mint
caps a key at 64 scopes).

Signed-off-by: colton <colton@futurebuild.ai>
…mers, audited grants and AI spend

PD-4, the stricter line: a branch bound key may read a customer shared with
another branch, but may not add or change a customer scoped pricing rule,
category rule or tax exemption for it, because the write prices the other
branch's sales to that customer too. A new exclusive customer wall holds
those writes to the customers the pin holds alone and refuses the rest with
a message naming the other branch; the reads keep the shared customer rule.
PD-5: the grant, revoke and home branch writes for the pin branch record
their audit rows (user.branch.granted, user.branch.revoked,
user.home_branch.set) with the machine key named as the actor. PD-6: the
parsing upload and the vision scan stay stated limits and each call records
its ai.parsing.upload or ai.vision.scan audit row with the key that spent
the dealer's AI credit (both handlers gain the audit logger serve wires).
The drivers ruling (PD-3) landed with the stated limit split.

Signed-off-by: colton <colton@futurebuild.ai>
…e code now does

The ADR's section 5.5 records the principal's rulings: WALLED is a tested
claim whose entries name their filter column; the category rule invariant and
its migration; the stricter shared customer line (read yes, priced writes
no, the refusal naming the other branch); the audited grant, revoke and home
branch writes; the stated limit as a read, never a write (the catalogue and
configuration writes, the drivers refused reads included, the AI spend
audited); and the account and calculation confinements. CONTRACT-CHANGES
gains the fix round 3 row naming every changed answer, and the delivery,
charge codes, products and locations module pages state the same: the fleet
split, the refused catalogue writes, the audited grants.

Signed-off-by: colton <colton@futurebuild.ai>
futurebuildai and others added 17 commits October 10, 2026 16:59
…shared customer

Principal rulings 43 to 45. On a customer held by the pin's branch and another,
a bound key may change only the branch local fields (email, phone); every other
field of the record, the salesperson and the escalation policy is restricted by
default (a field added later is restricted until listed as branch local), and
the key is refused in the PD-4 words naming the other branch, with the
key.branch_refused row. It may add a ship-to or a contact but change or delete
none of a shared customer's. A customer held by the pin alone, a user and
every unshared customer are unchanged. A dealer wide key changes the restricted
fields and each change is audited as customer.shared_restricted_changed with the
actor and the before and after value of every field. The rule lives in the
customer service (it must see the field diff) behind a SharedGuard that
customerguard adapts from the key branch wall.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: colton <colton@futurebuild.ai>
…n ids

Round 8 and 9 found the sweep vacuous: it sent {} bodies that failed validation
before the wall was reached, counted whole tables that other packages move, and
skipped about 25 walled routes. Every write leg now sends a body that passes
validation, shadowed by a control against the pin's own twin that must pass the
module's validation and reach the row (a leg with no possible control names why);
the foreign leg must answer 403 or 404 and never a scope refusal; changes are
measured by a digest of every row that names branch B's twin rows by id, never
by table counts. A coverage test holds the route allowlist against the legs and
fails by name for any WALLED route no bound key leg drives. The fixture now
builds the order service as serve does (price engine, exposure gate, quote
convert) and the draft change feed, and removes the rows and keys it makes.

The sweep found a real gap: the order exposure gate and override looked up the
order's source quote by id alone, so a key pinned to A could read the gate of,
and clear the exposure of, a quote behind branch B's order. Both now read the
order under the branch wall first.

Also: the branch named in the "also held by" refusal is the lowest numbered
branch that is not the pin, the same answer every time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: colton <colton@futurebuild.ai>
Round 7 P2-2: a migration 107 test over a scratch database holds the check
violation, the NOTICE naming the rows, the unrewritten rows, the NOT VALID
branch with planted rows and the validated branch without them.

P2-3: the shared customer writes test now drives DELETE and POST on the
category bulk routes and the audit read of a shared customer's rule.

P2-4: the AI spend and grant assertions measure a delta by action name and by
the key's own id; serve.go's own WithAuditLog calls for the location, parsing
and vision handlers are pinned in the source level wiring test.

P3: the dealer wide AP leg drives /transitions (the renamed route); the parsing
upload is audited before the extractor runs, so a failure after the AI call
still records the spend; the AP refusal notes call the dealer wide books a
policy choice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: colton <colton@futurebuild.ai>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: colton <colton@futurebuild.ai>
…hanges and the module pages

The sweep claim now says what it does (valid bodies, an own twin control, the
twins' ids, a coverage test), the body customer walls and the order ship to and
contact ownership are stated, and the shared customer rule carries its field by
field table (RESTRICTED or BRANCH LOCAL, with the reason) and the ship to and
contact rule. A SEC-BOUND-KEY-PIN-4 row joins the contract changes; the
customers, orders, quotes, payments, invoices and drafts pages carry the rule
that touches them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: colton <colton@futurebuild.ai>
Signed-off-by: colton <colton@futurebuild.ai>
…o the pin

Round 10 P1-1: POST /api/v1/pos/sync takes a customer on every replayed
sale and no wall held it, so a key pinned to branch A posted a sale (and
an ACCOUNT tender's invoice) on a customer held only by branch B. The
mount wall.pos composes the key branch wall's customer hold over the
body's items[].customer_id on the sync route only; the change lives in
the wall layer and the route allowlist, with no pos code touched. Red
first: TestKeyBranchPin_PosSyncCustomerWall fails on 4bad569 with the
sale synced and the foreign invoice written.

Signed-off-by: colton <colton@futurebuild.ai>
Round 11 P1-1 and round 10 P2-2: an order or a quote took a job_id of
another customer's project, and the order was then priced by that
customer's job rule. For every caller the order build (and the
conversion's buildFromQuote) and the quote priceDraft now refuse a
job_id whose project belongs to a different customer than the
document's, 400 naming job_id with the credit memo's words; the order
repository's OwnedBy carries the job beside the ship-to and the
contact. For a branch bound key the wall holds the body's job_id
through projects.customer_id on the order and quote creates and
updates (bodyDocumentCustomer over documentCustomersOf), the same
resolution the pricing calculate read carries. Red first:
TestKeyBranchPin_JobOfAnotherCustomer fails on 4bad569 with the
foreign job stored and the override's price on the A control.

Signed-off-by: colton <colton@futurebuild.ai>
Round 11 P2-1: a key pinned to A converted a quote at branch A whose
customer was held only by B, and the created order took B's tax
exemption. The conversion is the one path where an existing document
becomes a new one, so the quotes mount walls POST /quotes/{id}/convert
through the quote's stored customer (quoteRowCustomerOf), as the draft
promotion rechecks its payload: a quote a caller moved onto a foreign
customer is refused 403 naming customer_id with nothing written. Red
first: TestKeyBranchPin_QuoteConvertCustomerWall fails on the
pre-wall mount with the order created and B's exemption on it.

Signed-off-by: colton <colton@futurebuild.ai>
…ared customer

Round 11 P2-2 and round 10 P2-1: naming is_default true on a ship-to
add clears the customer's existing default, which on a shared customer
is a shared ship to every branch's delivery orders default to, so the
add changed a shared record through a route the rule classes as adds
only. CreateShipTo runs checkSharedChild on the add that names
is_default true before ClearDefaultShipTo, refused in the same words
naming the other branch (the refusal now also goes through
sharedOutcome so its audit row is written); a customer the pin holds
alone, a user and a dealer wide key keep the reach. Red first: the
SharedCustomerRecord leg fails on 4bad569 with the add 201 and the
seeded default cleared.

Signed-off-by: colton <colton@futurebuild.ai>
Round 11 P2-3: an activity of customer A named customer B's contact on
the create and the update, for every caller, so one branch's record
pointed at another's contact. The service checks the contact against
the activity's customer (ContactBelongsTo) and refuses with the
order's words, 400 naming contact_id. Red first:
TestKeyBranchPin_ActivityContactOwnership fails on 4bad569 with the
foreign contact written on the create and the update.

Signed-off-by: colton <colton@futurebuild.ai>
…field scope

Principal ruling item 48: a shared customer's restricted fields change
only with the finance or owner role (a user) or an unbound machine key
carrying a dedicated customers:restricted scope. The scope joins the
grant grammar (ValidScopeGrammar, of no route, so no module scope ever
grants it and no default grant carries it); the mint grants it only for
an owner (the admin role is refused, fail closed without claims) and
the mint's key.created audit row names it. SharedCustomer answers a
fourth verdict, RefuseCaller: a user without the finance or owner role
and an unbound key without the scope are refused in the PD-4 words
naming the lowest numbered branch holding the customer; a bound key is
refused even with the scope; the scoped key's change keeps its
customer.shared_restricted_changed audit row. The customer routes'
guard admits finance so the ruling's role can reach the fields. Red
first: TestKeyBranchPin_SharedRestrictedScope fails on 4bad569 with
the mint 400 and every caller change open.

Signed-off-by: colton <colton@futurebuild.ai>
…t, not a 500

Round 11 P3-1: a PUT of the legacy tier row that also names a customer
(the shape migration 107 leaves NOT VALID) revalidated the constraint
on the write and answered 500 for the unbound key and the user.
UpdateCategoryRule refuses such a row with a 409 naming the single
target invariant before the write, for every caller; the bound key is
refused at the pin first. Red first: the LegacyTierRowStaysDealerWide
PUT legs fail on the unfixed code with the 500.

Signed-off-by: colton <colton@futurebuild.ai>
… a customer

Round 11 P3-2: the body customer wall held POST /payments/intent too,
so a bound key naming any customer in a field the route does not
accept got the pin's 403 while every other caller got the route's own
400 unknown field. The intent carries no customer, so its pattern
leaves the body customer wall and the route's own 400 answers every
caller alike. Red first: the BodyCustomerWall intent leg fails on the
walled code with the 403.

Signed-off-by: colton <colton@futurebuild.ai>
…nature date

Round 10 P3-1, P3-2, P3-3, P3-5 and P3-6, and round 10 P3-4's code
half: the credit memo PUT wall gets its refused leg (the pattern
dropped from the invoices mount now goes red), the PUT body's
salesperson_id gets its refusal leg (classing it BRANCH LOCAL goes
red), the shared customer is held by a third branch so every also held
by answer must name the lowest numbered other branch (a DESC on either
lookup goes red), a service level refusal row names the method and
path RecordRequest noted (removing RecordRequest goes red), the walled
route coverage counts only explicit twin legs so an A control alone
covers nothing (a driven but control only route fails by name), and
the escalation audit's before and after values carry
agreement_signed_at beside the reference (policyFacts, missing on the
unfixed code).

Signed-off-by: colton <colton@futurebuild.ai>
…odule pages

ADR 0007 section 5.5 and ADR 0002 state the round 5 rules: the sync's
body customer wall, the job_id wall and the every caller job, contact
and default ship to ownership, the convert's customer recheck, the
intent outside the body customer wall, the customers:restricted
capability scope and the finance or owner user rule that resolves the
round 4 open question, the legacy row's 409, and the twin only
coverage. CONTRACT-CHANGES carries SEC-BOUND-KEY-PIN-5, and the
orders, quotes, payments, customers, crm activities and tech admin
module pages state each rule where the module states its own.

Signed-off-by: colton <colton@futurebuild.ai>
Signed-off-by: colton <colton@futurebuild.ai>
The synchronize event of the round 5 push was not delivered: no run,
check suite or check run exists for 88ca773 while the workflow kept
serving every other pull request. An empty commit re-fires it; the
tree is unchanged.

Signed-off-by: colton <colton@futurebuild.ai>
…ound-key-pin

Signed-off-by: colton <colton@futurebuild.ai>

# Conflicts:
#	core/internal/drafts/feed_c5_2a_p2_1_test.go
#	docs/modules/tech-admin.md
Signed-off-by: colton <colton@futurebuild.ai>
…ound-key-pin

Signed-off-by: colton <colton@futurebuild.ai>
…PR 83 r13 P2-1)

CreateTransaction, CreateTillSession and GetRegisterBranch derived the
register's branch only through locations, so a register whose location
names no branch skipped the pin check and wrote at the default branch.
COALESCE(l.branch_id, r.branch_id) makes the NOT NULL column the fallback.
Tests per register branch derivation plus r13's reproducing pos sync test;
the allowlist notes now say what the code reads.

Signed-off-by: colton <colton@futurebuild.ai>
…nd key's words (PR 83 r13 P3-1)

A user without the finance or owner role and an unbound key without
customers:restricted were refused with different words from the bound
key's and left no audit row. They now carry the bound key's words, and a
customer.shared_restricted_refused row names the actor, the fields, the
lowest numbered holding branch and the method and path.

Signed-off-by: colton <colton@futurebuild.ai>
…y scoped bound key (PR 83 r12 P3s)

Each test goes red when its guard is removed: the conversion's stored
job recheck, ownerGrantsRestricted with no claims, and a bound key that
carries customers:restricted on a shared customer.

Signed-off-by: colton <colton@futurebuild.ai>
…PR 83, item 54 question 1)

The customer group admitted finance on every route. A third role guard now
admits finance on the header write, the salesperson and the escalation
policy only; customer and contact create, the reads and the other writes
answer finance what refactor/v1 answered. No ADR or role catalog row
grants finance more.

Signed-off-by: colton <colton@futurebuild.ai>
… 83, item 54 question 2)

An activity has no branch tag, so on a customer another branch also holds
a bound key adds one and never changes or deletes one: 403 in the contact
rule's words with a key.branch_refused row, the activity untouched. The
crm service takes the shared guard through the customer guard adapter.

Signed-off-by: colton <colton@futurebuild.ai>
…and the module pages state fix round 6

Signed-off-by: colton <colton@futurebuild.ai>
…EG-01 onto Kelowna

The till session opened on a register with no branched location now carries
the register's branch, so branch_id appears in its body (the four pos goldens,
ids renumbered). The seed moves REG-01 onto Kelowna so a seeded register
belongs to the default branch the seed makes, which leaves every sale and
return amount unchanged. Listed in CONTRACT-CHANGES.

Signed-off-by: colton <colton@futurebuild.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant