Skip to content

Organization IAM Membership And School Sync Proof

Status: bounded SVC-005 proof accepted, not organization-service or rollout completion.

This page records the ownership and read-model contract for organization membership. IAM is the permission-bearing write authority; school-service is an organization/read-model owner and accepts membership changes only from the IAM sync contract.

IAM Membership Write Authority

IAM owns the canonical organization membership operations and emits iam.organization.member_added / iam.organization.member_removed events. The IAM membership outbox keeps delivery pending/retry/dead state in the IAM-owned database and the focused IAM usecase tests prove:

  • account/register, organization member add, invite acceptance, and removal enqueue membership events without waiting on the school-service publisher;
  • only the background dispatcher delivers the pending outbox rows, so a blocked school-service endpoint cannot delay the canonical IAM write;
  • publisher failures leave an outbox item due for retry;
  • successful dispatch marks the outbox delivered;
  • membership attributes include account/user id, organization id, role, status, unit, and event timing where available.

The IAM school-sync publisher at services/iam-service/internal/schoolsync/client.go maps those events to:

  • PUT /v1/organizations/{organizationId}/members/{userId} for add/update;
  • DELETE /v1/organizations/{organizationId}/members/{userId} for removal;
  • X-IAM-Membership-Sync: iam-service;
  • X-Internal-Service: iam-service and the shared X-Internal-Token;
  • X-IAM-Membership-Event-Id and X-IAM-Membership-Occurred-At ordering metadata;
  • bounded retry for transient failures and permanent rejection for unsupported roles.

School-service never mutates IAM roles, permissions, or canonical membership.

School-Service Read-Model Sync

The school-service endpoints under /v1/organizations/{organizationId}/members* require the IAM sync marker. They also require trusted X-Internal-Service: iam-service and X-Internal-Token; marker-only or spoofed requests receive 401 SCHOOL_INTERNAL_UNAUTHORIZED. Ordinary callers receive 409 SCHOOL_MEMBERSHIP_NOT_AUTHORITATIVE. Accepted sync responses carry X-HocTapAZ-Membership-Source: iam-sync-read-model.

The read model records event id/timestamp/action state and protects against:

  • stale upserts after a newer delete tombstone;
  • stale or replayed patches after a newer update;
  • replayed deletes for already absent rows;
  • local last-owner restrictions being applied to IAM canonical removal.

The focused HTTP tests cover the direct-write rejection, marker/header contract, stale replay/tombstone ordering, patch ordering, and last-owner read-model removal.

Gateway Route Proof

make test-organization-routes proves the route split:

  • deploy/gateway/routes.json keeps broad /api/organizations* legacy-backed;
  • non-default organization read routes target school-service only;
  • non-default member add/remove write routes target IAM through the gateway organization_membership_adapter;
  • school-service is never made the public permission-bearing member-write owner.

The member-write route rehearsal remains non-default and requires auth, organization context, and IAM authority. Student roster import and broad organization writes remain legacy-backed.

Student Roster Import Boundary

The versioned student.roster.import.requested.v1 contract is documented in contracts/events/README.md. It carries stable importJobId, rowId, and sourceEventId keys plus the organization/unit/classroom context and row display fields. The ownership sequence is explicit:

  1. IAM resolves or creates the account and writes canonical organization membership.
  2. Profile-service writes only student display/profile fields using the IAM account id.
  3. Classroom-service writes only local classroom membership using the IAM account id.
  4. School-service consumes IAM membership sync as a read model and does not create accounts, profiles, or classroom members.

Duplicate deliveries are replayed by row idempotency key; rejected rows are reported without replaying accepted owner mutations. No public native student-import route is promoted by this contract. The legacy route remains the active rollback target until all owner contracts have live, browser, and rollback proof.

Rollback Notes

Rollback is returning the gateway to deploy/gateway/routes.json, where broad organization routes remain legacy_proxy and native membership entries are absent. Disable the IAM school-sync publisher or leave its outbox pending if school-service is unavailable; IAM canonical membership remains authoritative.

bash
GATEWAY_ROUTE_TABLE=deploy/gateway/routes.json make run-gateway
make test-organization-routes

No default gateway route or shared deployment manifest was changed by this slice. Browser/live member-write proof and rollback-after-candidate review are still required before public promotion.

Required Evidence Boundary

  • IAM membership write authority proof: complete for the tested event/outbox contract;
  • school-service read-model sync proof: complete for marker, ordering, and tombstone behavior;
  • member-write route rollback notes: complete for the non-default rehearsal;
  • student roster import ownership contract: complete as a versioned boundary, with native route implementation and live/browser proof still open;
  • public/default route promotion: still blocked by runtime/browser and candidate rollback evidence, plus the open IAM/Profile dependency.

Verification

bash
GOTOOLCHAIN=go1.25.11 go test ./services/school-service/...
make test-organization-routes
GOTOOLCHAIN=go1.25.11 go test ./services/iam-service/internal/schoolsync -count=1
GOTOOLCHAIN=go1.25.11 go test ./services/iam-service/internal/usecase -run 'TestIAMPublishesOrganizationMembershipSyncEvents|TestIAMMembershipOutboxRetriesAfterPublisherFailure' -count=1
ruby -e 'require "yaml"; ARGV.each { |path| YAML.load_file(path) }' services/school-service/api/openapi.yaml contracts/openapi/services/school-service.yaml
diff -u services/school-service/api/openapi.yaml contracts/openapi/services/school-service.yaml
docker compose -f deploy/docker-compose.yml config --quiet

All commands passed on 2026-07-10.

Current No-Write Recheck (2026-07-15)

The current worktree rechecked the read-model boundary without a live bearer, organization, or member mutation:

  • GOTOOLCHAIN=go1.25.11 go test ./services/school-service/... -count=1 passed, including the school-service HTTP membership boundary coverage;
  • GOTOOLCHAIN=go1.25.11 go test ./services/iam-service/internal/schoolsync -count=1 passed;
  • the focused IAM publisher and outbox retry tests passed;
  • make test-organization-routes confirmed default organization routes remain legacy-backed while only non-default member-write examples target IAM; and
  • ORGANIZATION_MEMBERSHIP_WRITE_SELF_TEST=1 make test-organization-membership-write-live passed the smoke-script contract harness.

This recheck did not supply a bearer token, start a candidate Gateway, or send an IAM membership write. It refreshes contract and route-guard evidence only; the public member-write browser/runtime and rollback-after-candidate gates remain open.

Go-platform documentation is generated from repository Markdown.