# OFM MCP Protocol

Status: deployed prototype. This document is contract-only; internal implementation/infra are omitted. All responses use the common envelope `{ success, data?, error?, revision? }` with a stats block (which includes `build`).


## Base URL
- API Gateway: `https://api.g3nretailstack.com/ofm`
- Health check: `GET /ofm/stat` — public, no auth required.
- MCP protocol: <a href="https://mcp.g3nretailstack.com/ofm/PROTOCOL.md" target="_blank" rel="noopener noreferrer">https://mcp.g3nretailstack.com/ofm/PROTOCOL.md</a>

## MCP transport & resources
- Transport/auth/options: see <a href="https://doc.g3nretailstack.com/common/mcp.html" target="_blank" rel="noopener noreferrer">/common/mcp.html</a>.
- MCP resources include protocol docs, OpenAPI contracts, and doc pages (surfaces/calls/playbooks).
- Streaming: JSON only today; SSE is not enabled on API Gateway (streaming would use a dedicated endpoint).

## Auth + tenancy
- Auth placement: header auth is canonical for org-scoped APIs; body auth is accepted for compatibility where documented. See [/common/headers-identity.html](https://doc.g3nretailstack.com/common/headers-identity.html).
- API Gateway endpoints require either a valid **human USM session** (header `x-session-guid`, body `session_guid` accepted) or a valid **service-account API key** (header `x-api-key`, body `api_key` accepted). Org create requires a human session (invitation binds a user as the create/primary owner). Operator-only direct Lambdas (no session, IAM-gated): invitation ops (create/get/list/reject/doom + resolveInvitation) and admin remediation (`orgStatusSet`, `ownerPrimarySetAdmin`, `ownerStateSetAdmin`, `memberStateSetAdmin`).
- Owner-management ops (`/owner/primary/set`, `/owner/secondary/add|remove`, `/owner/state/set`) require the **active primary owner**. Other owner-only governance ops (org/cost-centre/facility) allow any active owner.
- Non-associated callers receive `404 not-found` on org-scoped reads (anti-enumeration).
- Suspended members are treated as non-associated (`404 not-found`). Suspended owners are associated but are not owners (`403 not-owner` on owner-only ops).
- Service-account reads require a view role (`ofm_view`, `pvv`, `pma`, `vca`, `pmc_view`, `pmc_publish`) or `owner`; otherwise `403 forbidden-role`.
- Facility-scoped checks require explicit logical assignments; missing assignment returns `403 forbidden-facility`.
- Frozen orgs block access with `403 org-access-blocked`. Tenant writes require `org.status=verified`; otherwise `409 org-write-blocked`.
- Pagination: list endpoints default to 8 items, clamp 1–256, and accept/return opaque `next_token` cursors.
- Optional cost attribution: provide header `x-cccode` (`^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`, canonicalized to uppercase). In OFM this is **telemetry-only** and is parsed as `cccode_attrib`; it is never merged into the domain `cccode` inputs/outputs (OFM uses `cccode` as the cost-centre identifier).

Codes & identity
- Most human codes (facility/zone/team) match `^[A-Z][A-Z0-9_-]{0,9}$`, case-insensitive, start with a letter, length ≤10. Orgcode is global and immutable. cccode is auto `XXXX-XXXX-XXXX` (global). Invitation code auto `XXX-XXX-XXXX`; referral code optional `XXX-XXXX-XXXX`. Relationships use GUIDs; parent org is immutable.
- Sales channels: `channel_code` is a registry-enforced lowercase `snake_case` value (no market/locale baked in). `market_code` is strict ISO-3166 alpha-2. Locale fields use BCP-47 canonical forms.
- Market/locale coupling: if a locale includes a region subtag (e.g., `en-US`), it must match `market_code` (so reject `market_code=CA` with `locale_codes=["en-US"]`).
- Sales channel external identifiers: optional `external_ids[]` entries `{ kind, value }` where `kind` is lowercase `snake_case` and `value` is canonicalized case-insensitively; max 10 per channel. Duplicate external ids across channel records are allowed for migrations, but the **active binding** is unique; resolve via `POST /sales-channel/resolve` prefers the active binding.

Status models
- Invitation: pending → accepted/rejected/expired/doomed (timestamped; used_by recorded on accept).
- Organization: unverified → verified/parked/suspended/frozen/doomed; verified ↔ parked; verified ↔ suspended; unverified/verified/parked/suspended → frozen (operator-only); frozen → doomed.
- Facilities/zones: active ↔ inactive; any → doomed (terminal).
- Cost centres/teams (as-built): active ↔ suspended; any → doomed (terminal).
- Owners/members: state active ↔ suspended; any → doomed (terminal). Owners carry flags {create_owner, primary_owner, secondary_owner} plus state.
- Member semantics: a member links a user to an org and is unique per org. States: active, suspended, doomed (terminal). Members are never deleted; duplicate add is a conflict.
- Sales channel declarations: `draft | active | inactive | doomed` with FSM `draft→active|doomed`, `active→inactive`, `inactive→active|doomed`; `active→doomed` is rejected (must go via inactive); `doomed` is terminal.

Base path and endpoints (deployed)
- Base: `https://api.g3nretailstack.com/ofm`
- Invitations are operator-only direct Lambda (not exposed on API Gateway): invitationCreate/get/list/reject/doom and resolveInvitation.
- Organization: `/org/create`, `/org/get`, `/org/list`, `/org/update`, `/org/status/set`.
- Cost centre: `/cost-centre/create|get|list|update|status/set`.
- Physical facility: `/facility/physical/create|get|list|update|status`.
- Legal facility: `/facility/legal/create|get|list|update|status`.
- Logical facility: `/facility/logical/create|get|list|update|status`.
- Zones: `/zone/create|get|list|status` (no update endpoint; caption/code immutable after create).
- Sales channels: `/sales-channel/create|get|list|resolve|update|status`, plus config upload flow: `/sales-channel/config/presign` and `/sales-channel/config/complete` (pointer metadata only; config JSON is stored in S3 and never emitted inline).
- Owners: `/owner/list`, `/owner/primary/set`, `/owner/secondary/add|remove`, `/owner/state/set`.
- Members: `/member/invite/create`, `/member/invite/accept`, `/member/invite/list`, `/member/invite/revoke`, `/member/add` (owner-only direct add, bypasses invite), `/member/state/set`, `/member/assign-logical`, `/member/detach-logical`, `/member/list`, `/member/names` (batch-resolve display names, any org member), `/member/assignments`, `/member/resolve`.
- Service-account facility delegation (owner-only): `/service-account/assign-logical`, `/service-account/detach-logical`, `/service-account/assignments` (service accounts + API keys are created in USM; OFM only stores logical assignments used by facility-grant checks).
- Teams: `/team/create|get|list|update|status`, `/team/member/add|remove`, `/team/members`, `/team/by-member` (self requires membership; querying another user requires owner; org_guid is always required for scoping).
- Resolvers: `/resolve/orgcode`, `/resolve/facility`, `/resolve/zone`, `/resolve/cost-centre`.
- Access notes:
  - `resolve/facility` and `resolve/cost-centre` are owner-only.
  - `resolve/zone` requires facility grant for the logical (or owner).
  - `sales-channel/get|list|resolve` require facility grant for the channel logical (or owner); org-wide list (no `logical_guid`) is owner-only.
  - `member/resolve` is human-session only (service-account API keys are rejected).

Errors / status codes (representative)
- 200 on success; envelope includes stats (with build metadata).
- 400 validation (code format, missing required fields, illegal FSM transition, depth>32, parent org mismatch).
- 401 missing/invalid credential (API GW surfaces: session or API key).
- 403 not owner / forbidden role / forbidden facility / org access blocked.
- 404 not found (resource, code→guid resolution miss).
- 409 conflict (uniqueness violation for codes/orgcode/cccode/invitation code, invitation already consumed/expired/doomed, code generation exhausted, org-write-blocked).
- 428 expected revision required (`expected-revision-required`) for revisioned state-changing operations.
- 410 gone (optional for expired/consumed invitation) or use 409 with error tag.
- 429 throttled; 500 internal-error.
- Error tags (suggested): `invalid-code`, `invalid-fsm-transition`, `not-owner`, `not-found`, `uniqueness-conflict`, `code-generation-exhausted`, `duplicate-member`, `invitation-consumed`, `invitation-expired`, `invalid-parent-org`, `invalid-depth`, `invalid-session`, `forbidden-role`, `forbidden-facility`, `org-access-blocked`, `org-write-blocked`, `throttled`, `internal-error`.

Optimistic concurrency (revision)
- Most stateful OFM records include a per-record `revision` GUID.
- For any state-changing operation that updates an existing revisioned record, the request must include `expected_revision`.
  - Missing `expected_revision` → HTTP `428` `expected-revision-required` with `error.details.{ current_revision, current_record }`.
  - Mismatch → HTTP `409` `conflict` with `error.details.{ provided_revision, current_revision, current_record }`.
- On mismatch, OFM emits a best-effort `revisionConflict` event for observability/audit.

Operations (representative contract shapes)
- invitationCreate (Lambda): req `{ caption?, expires_at_utc?, referral_code?, schedule?, reason? }` → `{ invitation_guid, code, status, expires_at_utc, referral_code?, schedule?, created_at, updated_at }`
- invitationReject (Lambda): req `{ code, expected_revision, reason? }` → `{ code, status, revision }`
- invitationDoom (Lambda): req `{ code, expected_revision, reason? }` → `{ code, status, revision }`
- invitationGet/List (Lambda): filters by status/code; records include `used_by { org_guid?, user_guid?, session_guid?, accepted_at_utc? }`, status history.
- orgCreate (API, session): req `{ orgcode, caption?, timezone?, fiscal_calendar?, invitation_code, reason? }` → `{ org_guid, orgcode, status, invitation:{guid,code,referral_code?,schedule?}, owners:{create_owner_user_guid, primary_owner_user_guid}, cost_centre:{cc_guid, cccode} }` (auto-creates a master cost centre). Note: `orgcode` is **required** (must match `^[A-Z][A-Z0-9_-]{0,9}$`, globally unique, immutable).
- orgGet (API, session): req `{ org_guid|orgcode }` → org snapshot including `cost_centre_guid`, `cost_centre { cc_guid, cccode }`, and `search_plane` when configured.
- orgStatusSet (owner): req `{ org_guid, expected_revision, status, reason?, reason_code? }` → `{ org_guid, status, revision }`
- orgUpdate (owner): req `{ org_guid, expected_revision, caption?, timezone?, fiscal_calendar?, search_plane?, reason? }` → `{ org_guid, revision }` (set `search_plane` to `null` to clear)
- costCentreCreate (owner): req `{ org_guid, caption?, reason? }` → `{ cc_guid, cccode, status }`
- costCentreGet/Update/Status (owner): update/status require `expected_revision` (by cc_guid|cccode)
- physicalCreate/Update/Status/Get/List (owner): code unique per org; address/contacts in payloads
- legalCreate/Update/Status/Get/List (owner): code unique per org
- logicalCreate/Update/Status/Get/List (owner): requires physical_guid + legal_guid same org; optional cost_centre_guid; code unique per org
- zoneCreate/Status/Get/List (owner): `{ org_guid, logical_guid, parent_zone_guid?, code, caption?, reason? }`, parent defaults to ROOT, depth ≤32, code unique per logical; `ROOT` is seeded on `facility/logical/create` (and `zone/create` ensures it exists for legacy logicals). No zone update endpoint; recreate if caption/code change is required.
- owners: list; setPrimary (current primary only); addSecondary/removeSecondary; setState (primary owner only)
- members: inviteCreate/inviteAccept; setState; assignLogical/detach; list; role-profile fields on member and member-logical edges (e.g., `role_profile_id`, `role_version`, `grants`, `effective_from/to`, `suspended`, `notes`)
- teams: create/update/status; add/remove member; list teams; list team members (flat teams)
- resolvers: orgcode→org_guid; invitation code→inv_guid; facility/zone codes→guid (scoped); cccode→cc_guid

Endpoint tables (representative; all return envelope + stats; 4xx/5xx per validation/auth/fsm errors)
- invitationCreate (Lambda): req fields { `caption?`, `expires_at_utc?` (ISO; >now; <=+120d), `referral_code?`, `schedule?` (JSON), `reason?` }; resp data { `invitation_guid`, `code`, `status`, `expires_at_utc`, `referral_code?`, `schedule?`, `created_at`, `updated_at` }.
- invitationReject (API owner): req { `code`, `expected_revision`, `reason?` }; resp { `code`, `status`, `revision` }.
- invitationDoom (Lambda): req { `code`, `expected_revision`, `reason?` }; resp { `code`, `status`, `revision` }.
- invitationGet/List (API owner): query `status?`, `code?`; items include `used_by { org_guid?, user_guid?, session_guid?, accepted_at_utc? }`, `status_history`.
- orgCreate (API session): req { `orgcode` (required, global unique, immutable), `caption?`, `timezone?`, `fiscal_calendar?`, `invitation_code`, `reason?` }; resp { `org_guid`, `orgcode`, `status`, `invitation { guid, code, referral_code?, schedule? }`, `owners { create_owner_user_guid, primary_owner_user_guid }`, `cost_centre { cc_guid, cccode }`, `timezone?`, `fiscal_calendar?` } and sets invitation→accepted with `used_by` (master cost centre auto-created).
- orgStatusSet (owner): req { `org_guid`, `expected_revision`, `status`, `reason?`, `reason_code?` } → resp { `org_guid`, `status`, `revision` } (FSM enforced).
- orgUpdate (owner): req { `org_guid`, `expected_revision`, `caption?`, `timezone?`, `fiscal_calendar?`, `search_plane?`, `reason?` } → resp { `org_guid`, `revision` } (set `search_plane=null` to clear).
- costCentreCreate (owner): req { `org_guid`, `caption?`, `reason?` } → { `cc_guid`, `cccode`, `status` }.
- costCentreGet/Update/Status (owner): update/status require `expected_revision` (req by `cc_guid|cccode`, mutable `caption?`, `status?`, `reason?`) → CC snapshot.
- physicalCreate (owner): req { `org_guid`, `code`, `caption?`, `address { street, city, region, country }`, `phone`, `fax?`, `email?`, `primary_contact?`, `reason?` } → { `pf_guid`, `code`, `status`, ... }.
- physicalUpdate/Status (owner): require `expected_revision` (req by `pf_guid|code`, `org_guid`, optional `code/caption/address/phone/fax/email/primary_contact/status`, `reason?`) → snapshot.
- legalCreate/Update/Status (owner): code unique per org; caption/status mutable → snapshot.
- logicalCreate (owner): req { `org_guid`, `code`, `caption?`, `physical_guid`, `legal_guid`, `cost_centre_guid?`, `reason?` } (physical/legal same org) → { `logical_guid`, `code`, `status`, refs }.
- logicalUpdate/Status (owner): require `expected_revision` (req by `logical_guid|code`, `org_guid`, optional `code/caption/status/cost_centre_guid`, `reason?`) → snapshot.
- zoneCreate (owner): req { `org_guid`, `logical_guid`, `parent_zone_guid`, `code`, `caption?`, `reason?` } depth≤32 → { `zone_guid`, `code`, `status`, `depth`, `parent_zone_guid` }.
- zoneStatus (owner): req { `org_guid`, `logical_guid`, `zone_guid`, `expected_revision`, `status`, `reason?` } → { `org_guid`, `logical_guid`, `zone_guid`, `status`, `revision` }; get/list by logical_guid (optional parent filter) with parent/children in response.
- owners: list `{ org_guid }`; setPrimary (current primary only) `{ org_guid, user_guid, expected_revision, reason? }`; addSecondary/removeSecondary `{ org_guid, user_guid, expected_revision?, reason? }`; setState `{ org_guid, user_guid, expected_revision, state, reason? }` (primary owner only).
- members:
  - memberAdd (owner-only, direct add bypassing invite) `{ org_guid, user_guid, state?, role_profile_id?, role_version?, grants?, effective_from?, effective_to?, notes? }` → `{ org_guid, user_guid, state, revision }`
  - inviteCreate `{ org_guid, caption?, invitee_user_guid, expires_at_utc?, role_profile_id?, role_version?, grants?, effective_from?, effective_to?, notes?, reason? }` → `{ code, invite_guid, status }`
  - inviteList `{ org_guid, status?, limit?, next_token? }` → `{ invites[], next_token? }`
  - inviteRevoke `{ org_guid, invite_guid?|code?, expected_revision, reason? }` → `{ invite_guid, status: "doomed", revision }`
  - inviteAccept `{ code, reason? }` → `{ org_guid, user_guid, state, revision }` (404 if the code is bound to a different user; 409 if member already exists in `state=doomed`)
  - setState `{ org_guid, user_guid, expected_revision, state, reason? }`
  - assignLogical/detach `{ org_guid, user_guid, logical_guid, expected_revision?, role_profile_id?, role_version?, grants?, effective_from?, effective_to?, suspended?, notes?, reason? }` (`expected_revision` required for detach)
  - list/assignments with filters (self assignments: omit `user_guid`)
- teams (flat): create `{ org_guid, logical_guid?, code?, caption?, reason? }`; update/status require `expected_revision` (req `{ org_guid, team_guid|code, expected_revision, ... }`); add member `{ org_guid, team_guid, user_guid, reason? }`; remove member `{ org_guid, team_guid, user_guid, expected_revision, reason? }`; list teams/members (optional `logical_guid` filter); by-member (self requires membership, others require owner; org-scoped) `{ org_guid, user_guid?, limit?, next_token? }`.

Owner/member/team transition details (API Gateway, session required unless Lambda noted)
- ownerPrimarySet: `{ org_guid, user_guid, expected_revision, reason? }` (caller must be the current primary owner; `expected_revision` is the org record revision); switches primary_owner_user_guid.
- ownerSecondaryAdd/Remove: `{ org_guid, user_guid, expected_revision?, reason? }` (owner; `expected_revision` required when updating existing owner record); flags secondary_owner=true/false.
- ownerStateSet: `{ org_guid, user_guid, expected_revision, state, reason? }` (owner); states active/suspended/doomed; doom is terminal.
- memberStateSet: `{ org_guid, user_guid, expected_revision, state, reason? }` (owner); allows reactivation from suspended; doomed is terminal.
- teamStatusSet: `{ org_guid, team_guid|code, expected_revision, status, reason? }` (owner); states active/suspended/doomed.
- teamMemberAdd/Remove: `{ org_guid, team_guid, user_guid, expected_revision?, reason? }` (owner; `expected_revision` required for remove); duplicate active add is a conflict; add after doom is allowed.

Admin-only direct Lambdas (operator remediation)
- **ownerPrimarySetAdmin** — `{ org_guid, user_guid, expected_revision, reason? }` → `{ org_guid, user_guid, primary_owner: true, revision }` (operator-only direct Lambda; function `ofm_ownerprimaryset_admin`). Bypasses the "caller must be current primary owner" constraint; used for break-glass ownership recovery.
- **ownerStateSetAdmin** — `{ org_guid, user_guid, state, expected_revision, reason? }` → `{ org_guid, user_guid, state, revision }` (operator-only direct Lambda; function `ofm_ownerstateset_admin`). Sets owner state (active/suspended/doomed) without requiring a session from the primary owner.
- **memberStateSetAdmin** — `{ org_guid, user_guid, state, expected_revision, reason? }` → `{ org_guid, user_guid, state, revision }` (operator-only direct Lambda; function `ofm_memberstateset_admin`). Sets member state without requiring an owner session; used for compliance/offboarding remediation.
- **bulkImport** — `ofm_bulk_import` (direct Lambda, 15min/2048MB). Imports OFM entities (orgs, facilities, zones, members) from NDJSON in S3. Part of the multi-service bulk import pipeline (see PLAYBOOK.md section 6).
- **bulkImportStatus** — `ofm_bulk_import_status` (direct Lambda, 10s/512MB). Returns import job status for a given `run_id`.

Resolvers (API Gateway, session)
- orgcode → org_guid, facility code → pf/lg/lq guid (scoped to org), zone code → zone_guid (scoped to logical), cccode → cc_guid (global).

CLI / HTTP examples (representative)
- Create org via invitation + session: `curl -XPOST https://api.g3nretailstack.com/ofm/org/create -H 'content-type: application/json' -d '{"orgcode":"ACMESTORE","invitation_code":"ABC-DEF-GHIJ","session_guid":"...","user_guid":"..."}'`
- List members: `curl -XPOST https://api.g3nretailstack.com/ofm/member/list -H 'content-type: application/json' -d '{"org_guid":"<org>","limit":8,"session_guid":"..."}'`
- Team by member (self lookup): `curl -XPOST https://api.g3nretailstack.com/ofm/team/by-member -H 'content-type: application/json' -d '{"org_guid":"<org>","session_guid":"..."}'`
- resolvers: orgcode→org_guid; facility/zone codes→guid within scope; cccode→cc_guid. (invitation code→inv_guid is operator-only direct Lambda.)
- CLI parity (examples): `g3n ofm org-create --orgcode ACME --invitation-code ABC-DEF-GHIJ --user-guid <user> --session-guid <session> --profile g3nretailstack`; `g3n ofm member-state-set --org-guid <org> --user-guid <user> --state suspended --expected-revision <rev> --session-guid <session> --profile g3nretailstack`; `g3n ofm team-member-add --org-guid <org> --team-guid <team> --user-guid <user> --session-guid <session> --profile g3nretailstack`; `g3n ofm zone-list --org-guid <org> --logical-guid <lq> --parent-zone-guid ROOT --limit 8 --session-guid <session> --profile g3nretailstack` (all commands accept `--base-url` to override and clamp limit 1–256, returning `next_token` objects to pass via `--next-token`).

Examples (full envelope + error tags)

- orgCreate (success, API Gateway)
  - Request:
    ```json
    {
      "orgcode": "ACMECORP",
      "caption": "ACME Corp",
      "invitation_code": "ABC-DEF-1234",
      "session_guid": "sess-123",
      "user_guid": "user-1"
    }
    ```
  - Response:
    ```json
    {
      "success": true,
      "data": {
        "org_guid": "org-123",
        "orgcode": "ACMECORP",
        "status": "unverified",
        "invitation": {
          "guid": "inv-123",
          "code": "ABC-DEF-1234",
          "referral_code": "ABCD-1234-XYZ",
          "schedule": { "plan": "standard" }
        },
        "owners": {
          "create_owner_user_guid": "user-1",
          "primary_owner_user_guid": "user-1"
        },
        "cost_centre": { "cc_guid": "cc-1", "cccode": "ABCD-EF12-GHI3" }
      },
      "revision": "rev-org-1",
      "stats": { "call": "orgCreate", "service": "ofm", "timestamp_utc": "2026-02-05T00:00:00Z", "request_id": "req-123", "build": { "build_major": "MONDAY", "build_minor": "1770260725", "build_id": "MONDAY-1770260725" } }
    }
    ```

- orgStatusSet (error: missing expected_revision → 428)
  - Request:
    ```json
    {
      "org_guid": "org-123",
      "status": "verified",
      "session_guid": "sess-123"
    }
    ```
  - Response:
    ```json
    {
      "success": false,
      "error": {
        "major": { "tag": "expected-revision-required", "message": { "en_US": "expected_revision required" } },
        "details": {
          "current_revision": "rev-org-1",
          "current_record": { "org_guid": "org-123", "status": "unverified", "revision": "rev-org-1" }
        }
      },
      "stats": { "call": "orgStatusSet", "service": "ofm", "timestamp_utc": "2026-02-05T00:00:01Z", "request_id": "req-124", "build": { "build_major": "MONDAY", "build_minor": "1770260725", "build_id": "MONDAY-1770260725" } }
    }
    ```

- teamUpdate (error: not owner → 403)
  - Request:
    ```json
    {
      "org_guid": "org-123",
      "team_guid": "team-1",
      "expected_revision": "rev-team-1",
      "caption": "Ops Team",
      "session_guid": "sess-999"
    }
    ```
  - Response:
    ```json
    {
      "success": false,
      "error": {
        "major": { "tag": "not-owner", "message": { "en_US": "Owner permission required" } }
      },
      "stats": { "call": "teamUpdate", "service": "ofm", "timestamp_utc": "2026-02-05T00:00:02Z", "request_id": "req-125", "build": { "build_major": "MONDAY", "build_minor": "1770260725", "build_id": "MONDAY-1770260725" } }
    }
    ```
- team create and add member (API) requests:
  ```
  { "org_guid": "org-123", "code": "OPS", "caption": "Operations" }
  { "org_guid": "org-123", "team_guid": "team-1", "user_guid": "user-2" }
  ```
  Response data:
  ```
  { "team_guid": "team-1", "code": "OPS", "status": "active" }
  { "team_guid": "team-1", "user_guid": "user-2", "state": "active" }
  ```
- resolver orgcode (API) request:
  ```
  { "orgcode": "ACMECORP" }
  ```
  Response data:
  ```
  { "org_guid": "org-123" }
  ```
- orgStatusSet (API) request:
  ```
  { "org_guid": "org-123", "status": "verified", "expected_revision": "<org_revision>", "reason": "profile validated" }
  ```
  Response data:
  ```
  { "org_guid": "org-123", "status": "verified" }
  ```
- memberInviteCreate (API) request:
  ```
  { "org_guid": "org-123", "caption": "Ops lead", "role_profile_id": "ops-admin", "grants": ["assign","approve"] }
  ```
  Response data:
  ```
  { "org_guid": "org-123", "invite_guid": "inv-1", "code": "ABC-DEF-1234", "status": "active", "revision": "<rev>" }
  ```
- memberInviteAccept (API) request:
  ```
  { "code": "ABC-DEF-1234" }
  ```
  Response data:
  ```
  { "org_guid": "org-123", "user_guid": "user-3", "state": "active", "revision": "<rev>" }
  ```
- invitationReject (Lambda) request:
  ```
  { "code": "ABC-DEF-1234", "expected_revision": "<inv_revision>", "reason": "obsolete" }
  ```
  Response data:
  ```
  { "code": "ABC-DEF-1234", "status": "rejected" }
  ```

Eventing
- Follows existing pattern: stats block (includes build metadata), redaction. Emit on create/update/status changes (invitation acceptance includes used_by). External docs are contract-only; infra details remain internal.

## Roles
OFM uses a mix of owner-based and role-based authorization. Auth levels (least to most restrictive):
- `requireSession` — any authenticated user with a valid session.
- `requireOrgAssociation` — user must be associated with the org (owner or active member).
- `requireFacilityGrant` — user must have a facility-level grant for the logical entity.
- `requireOwnerOrRole(['ofm_member_admin'])` — owner or member with `ofm_member_admin` grant (member add/invite/state/assign/detach).
- `requireOwnerOrRole(['ofm_team_admin'])` — owner or member with `ofm_team_admin` grant (team create/update/status/member add/remove).
- `requireOwnerOrRole(['ofm_channel_admin'])` — owner or member with `ofm_channel_admin` grant (sales channel status/inventory sources).
- `requireOwner` — user must be an active owner (org update, facility CRUD, cost centre CRUD, legal entity CRUD).
- `requirePrimaryOwner` — user must be the primary owner (owner state changes).

Role profiles (`role_profile_id`, `role_version`) are managed as data fields on member records and expanded by `auth-direct` at request time.
Service-account reads require a view role (`ofm_view`, `pvv`, `pma`, `vca`, `pmc_view`, `pmc_publish`) or `owner`.

## Example envelopes
Success:
```json
{
  "success": true,
  "data": { "org_guid": "org-abc", "orgcode": "ACME", "status": "verified" },
  "stats": { "service": "ofm", "call": "orgGet", "timestamp_utc": "2026-01-01T00:00:00Z", "request_id": "req-1", "build": { "build_major": "MONDAY", "build_minor": "0000000000", "build_id": "MONDAY-0000000000" } }
}
```
Error:
```json
{
  "success": false,
  "error": {
    "error_code": "ofm.not_owner",
    "http_status": 403,
    "retryable": false,
    "major": { "tag": "not-owner", "message": { "en_US": "Owner permission required" } }
  },
  "stats": { "service": "ofm", "call": "orgGet", "timestamp_utc": "2026-01-01T00:00:00Z", "request_id": "req-1", "build": { "build_major": "MONDAY", "build_minor": "0000000000", "build_id": "MONDAY-0000000000" } }
}
```


## Endpoint inventory (OpenAPI parity)
The endpoints below are implemented and defined in `/ofm/openapi.yaml`. Request/response schema names reference OpenAPI component schemas.

| Method | Path | Request schema | Response schema |
| --- | --- | --- | --- |
| POST | /cost-centre/create | (inline) | (inline) |
| POST | /cost-centre/get | (inline) | (inline) |
| POST | /cost-centre/list | (inline) | (inline) |
| POST | /cost-centre/status/set | (inline) | (inline) |
| POST | /cost-centre/update | (inline) | (inline) |
| POST | /facility/legal/create | (inline) | (inline) |
| POST | /facility/legal/get | (inline) | (inline) |
| POST | /facility/legal/list | (inline) | (inline) |
| POST | /facility/legal/status | (inline) | (inline) |
| POST | /facility/legal/update | (inline) | (inline) |
| POST | /facility/logical/create | (inline) | (inline) |
| POST | /facility/logical/get | (inline) | (inline) |
| POST | /facility/logical/list | (inline) | (inline) |
| POST | /facility/logical/status | (inline) | (inline) |
| POST | /facility/logical/update | (inline) | (inline) |
| POST | /facility/physical/create | (inline) | (inline) |
| POST | /facility/physical/get | (inline) | (inline) |
| POST | /facility/physical/list | (inline) | (inline) |
| POST | /facility/physical/status | (inline) | (inline) |
| POST | /facility/physical/update | (inline) | (inline) |
| POST | /member/add | (inline) | (inline) |
| POST | /member/assign-logical | (inline) | (inline) |
| POST | /member/assignments | (inline) | (inline) |
| POST | /member/detach-logical | (inline) | (inline) |
| POST | /member/invite/accept | (inline) | (inline) |
| POST | /member/invite/create | (inline) | (inline) |
| POST | /member/invite/list | (inline) | (inline) |
| POST | /member/invite/revoke | (inline) | (inline) |
| POST | /member/list | (inline) | (inline) |
| POST | /member/names | (inline) | (inline) |
| POST | /member/resolve | (inline) | (inline) |
| POST | /member/state/set | (inline) | (inline) |
| POST | /override-code/generate | (inline) | (inline) |
| POST | /override-code/list | (inline) | (inline) |
| POST | /override-code/revoke | (inline) | (inline) |
| POST | /override-code/validate | (inline) | (inline) |
| POST | /org/create | (inline) | (inline) |
| POST | /org/get | (inline) | (inline) |
| POST | /org/list | (inline) | (inline) |
| POST | /org/status/set | (inline) | (inline) |
| POST | /org/update | (inline) | (inline) |
| POST | /owner/list | (inline) | (inline) |
| POST | /owner/primary/set | (inline) | (inline) |
| POST | /owner/secondary/add | (inline) | (inline) |
| POST | /owner/secondary/remove | (inline) | (inline) |
| POST | /owner/state/set | (inline) | (inline) |
| POST | /profile/assign | (inline) | (inline) |
| POST | /profile/create | (inline) | (inline) |
| POST | /profile/get | (inline) | (inline) |
| POST | /profile/list | (inline) | (inline) |
| POST | /profile/resolve | (inline) | (inline) |
| POST | /profile/status | (inline) | (inline) |
| POST | /profile/unassign | (inline) | (inline) |
| POST | /profile/update | (inline) | (inline) |
| POST | /resolve/cost-centre | (inline) | (inline) |
| POST | /resolve/facility | (inline) | (inline) |
| POST | /resolve/orgcode | (inline) | (inline) |
| POST | /resolve/zone | (inline) | (inline) |
| POST | /sales-channel/config/complete | (inline) | (inline) |
| POST | /sales-channel/config/presign | (inline) | (inline) |
| POST | /sales-channel/create | (inline) | (inline) |
| POST | /sales-channel/get | (inline) | (inline) |
| POST | /sales-channel/inventory-sources/get | (inline) | (inline) |
| POST | /sales-channel/inventory-sources/set | (inline) | (inline) |
| POST | /sales-channel/list | (inline) | (inline) |
| POST | /sales-channel/resolve | (inline) | (inline) |
| POST | /sales-channel/status | (inline) | (inline) |
| POST | /sales-channel/update | (inline) | (inline) |
| POST | /service-account/assign-logical | (inline) | (inline) |
| POST | /service-account/assignments | (inline) | (inline) |
| POST | /service-account/detach-logical | (inline) | (inline) |
| POST | /shift/create | (inline) | (inline) |
| POST | /shift/get | (inline) | (inline) |
| POST | /shift/list | (inline) | (inline) |
| POST | /shift/status | (inline) | (inline) |
| POST | /shift/update | (inline) | (inline) |
| GET | /stat | — | (inline) |
| POST | /station/create | (inline) | (inline) |
| POST | /station/get | (inline) | (inline) |
| POST | /station/list | (inline) | (inline) |
| POST | /station/status | (inline) | (inline) |
| POST | /station/update | (inline) | (inline) |
| POST | /team/by-member | (inline) | (inline) |
| POST | /team/create | (inline) | (inline) |
| POST | /team/get | (inline) | (inline) |
| POST | /team/list | (inline) | (inline) |
| POST | /team/member/add | (inline) | (inline) |
| POST | /team/member/remove | (inline) | (inline) |
| POST | /team/members | (inline) | (inline) |
| POST | /team/status | (inline) | (inline) |
| POST | /team/update | (inline) | (inline) |
| POST | /timesheet/clock-in | (inline) | (inline) |
| POST | /timesheet/clock-out | (inline) | (inline) |
| POST | /timesheet/get | (inline) | (inline) |
| POST | /timesheet/list | (inline) | (inline) |
| POST | /timesheet/void | (inline) | (inline) |
| POST | /zone/create | (inline) | (inline) |
| POST | /zone/get | (inline) | (inline) |
| POST | /zone/list | (inline) | (inline) |
| POST | /zone/status | (inline) | (inline) |

## Idempotency & retries
- All **GET / list / resolve / search** calls are safe to retry with identical inputs (read-only, no side effects).
- **POST mutations** that accept `expected_revision` use optimistic concurrency: on `409 conflict` or `428 expected-revision-required`, re-read the record, obtain the current `revision`, and retry with the updated value.
- Creates are generally **not** idempotent. Prefer caller-provided `code` (where supported) and verify existence before retrying a failed create.
- Bulk or scheduled jobs that accept an `idempotency_key` will de-duplicate within the documented time window.

## Known pitfalls
- **Missing `expected_revision`**: most state-changing operations require it; omitting it returns `428` with the current revision in `error.details`.
- **Stale revision**: reading a record, waiting, then writing with an outdated `revision` triggers `409`. Always use the latest revision from the most recent read.
- **Pagination cursors**: `next_token` is opaque JSON. Do not modify, decode, or persist cursors across sessions — they may change format between deploys.
- **Anti-enumeration 404**: some org-scoped reads return `404` even when the record exists, if the caller is not associated with the org. Treat `404` as ambiguous; verify caller association before assuming "not found".

## OpenAPI
- Contract schema: <a href="https://doc.g3nretailstack.com/ofm/openapi.yaml" target="_blank" rel="noopener noreferrer">https://doc.g3nretailstack.com/ofm/openapi.yaml</a>

## Usage patterns (headless)
- Stack-wide SOPs & operations catalog: <a href="https://doc.g3nretailstack.com/story/operations.html" target="_blank" rel="noopener noreferrer">/story/operations.html</a>.
- Super-usecase scenarios + QA status: <a href="https://doc.g3nretailstack.com/story/super-usecases.html" target="_blank" rel="noopener noreferrer">/story/super-usecases.html</a>.
- This protocol stays contract-only; use the catalogs for workflow expectations.


_Build MONDAY-1776194870 • 2026-04-14T19:27:50.000Z • [© 1999 Microhouse Systems Inc. All rights reserved.](https://doc.g3nretailstack.com/common/copyright-license.html)_
