Files

7.7 KiB

Hand-off: build multi-tenancy

Status: ready, 2026-09-18. For the implementer of feature 1 in SPEC.md §4.

This brief is everything an implementer needs besides the repository. The spec it builds from is docs/spec/features/multi-tenancy.md, committed before any implementation (SPEC.md §3, rule 3). Read that first, then this.

Who may do this

SPEC.md §3, rule 2: the implementer must never have read Stalwart Enterprise-only code. For an AI session that means a new session, with no access to the one that wrote the specs. That session had seen Enterprise snippets.

Start it in /run/media/john/PROJECTS/inbuxa-server, not in the parent folder, so it doesn't load the spec session's memory.

What the implementer may read

  • This repository, all of it. Every file is AGPL: the strip step removed everything Enterprise-only before the code was committed, and upstream's git history was never imported (SPEC.md §2.2a).
  • docs/spec/: the specs, the contract, this brief.
  • RFCs and IETF drafts.
  • The running behavior of a server built from this repository.

What the implementer must not read

  • Any checkout, archive, fork or web view of stalwartlabs/stalwart (any branch, tag or commit). Upstream's own files still carry the Enterprise snippets inline.
  • Any folder holding an upstream checkout or a strip work tree. That includes every …/scratchpad/stalwart and …/scratchpad/strip-* under /tmp/claude-1000/.
  • Transcripts of other sessions, under ~/.claude/projects/.
  • Stalwart Enterprise binaries, and the license portal.
  • INBUXA's production server. Its behavior is already recorded in the spec's "Observed" section. Build against the spec and local test servers.

If the implementer reaches a question the spec doesn't answer, they stop and ask. The answer goes into the spec as a Decision and is committed before the code that depends on it. They never go looking for how upstream does it.

What exists today

  • Data model: complete. The stripped build keeps every tenancy object and field: x:Tenant, TenantStorageQuota, memberTenantId on every type the spec lists, Authentication.defaultTenantRoleIds, the TenantMaintenance task, the resetTenantQuotas maintenance type, the limit.tenant-quota event and the permission names. They live in the generated registry code (crates/registry/src/schema/) and resources/schema/schema.json.gz. At first boot, x:Tenant/query answered an empty list.

  • Behavior: missing or partial. Upstream's enforcement was Enterprise-only, and the strip step removed it. Some checks may survive in shared code; the foreign-key checks, for example, are general-purpose. Nobody has measured what's left. Test each requirement against the spec, and treat any tenancy behavior you find as a starting point to check, not as the reference.

  • Signposts. rg "inbuxa:" lists the hooks left for rebuilding. The two for tenancy:

    • crates/common/src/auth/permissions.rs: the tenant permission ceiling (MT-13).
    • crates/common/src/lib.rs: the logo lookup (MT-22).
  • Where code was removed. docs/fork/strip-reports/v0.16.22.json lists, file by file, how many Enterprise snippets were taken out. It gives locations, not code. For tenancy, the surrounding AGPL code in these files shows the seams:

    Area Files
    Principals and access crates/common/src/auth/{authentication,mod,permissions}.rs, crates/common/src/cache/{principals,directory,invalidate}.rs
    JMAP registry crates/jmap/src/registry/{get,query,set}.rs
    JMAP mappings crates/jmap/src/registry/mapping/{principal,queued_message,task}.rs
    HTTP permissions crates/http/src/auth/permissions.rs
    Quotas crates/common/src/storage/quota.rs
    Tasks crates/services/src/task_manager/maintenance.rs

    Not every snippet in those files is about tenancy. Some belong to other features.

  • Tests. The integration suite (tests/) gates Enterprise tests behind the pending-rebuild Cargo feature. tests/src/system/mod.rs still calls tenant::test, but upstream's tenant.rs was Enterprise-only and is gone. Its mod tenant; declaration went with it. Write a new tests/src/system/tenant.rs from the spec's acceptance tests, declare the module again, and remove the gate on the call when it passes.

Where the code goes

SPEC.md §2.3: rebuilt features live in one new crate of the fork's own, crates/features (package inbuxa-features). License it AGPL-3.0-only, with the header used in crates/types/src/branding.rs. Keep upstream files' changes to the smallest hooks that call into it. Mark each hook with an inbuxa: comment naming the requirement (// inbuxa: MT-13). The crate is where later features go too, one module each: masked email, undelete and the rest.

Pick the smallest hook that works over restructuring an upstream file. Every upstream re-import merges into these files, so small hooks mean small conflicts.

What "done" means

  1. Every requirement MT-1 to MT-23 is implemented, or deliberately deferred with a line in the spec saying so. The branding and quota warnings for ihasmail can wait for ihasmail-inbuxa.
  2. Acceptance tests 1 to 14 pass as integration tests (tests/src/system/tenant.rs), with the pending-rebuild gate removed from the tenant call.
  3. Test 15 (compat, INBUXA's own data) needs a copy of production data from John. Write it so it can run against a data directory, and leave it unrun until he provides one.
  4. The whole workspace builds, and every unit test and every non-gated integration test that passed before still passes.
  5. A local end-to-end check: a first boot of the debug binary with two tenants, their admins and users, and mail between them. Use the container recipe in "Working rules".

Working rules

  • Commits: plain messages, no AI attribution, no co-author trailers and no session links. Each names the requirement IDs it implements. Commit to main in small steps. This repository is local only and has no remote.

  • Builds go to the repository's target/ with CARGO_TARGET_DIR=target TMPDIR=$PWD/target/tmp. /tmp is a 46 GB tmpfs, and a full build fills it.

  • http is ambiguous for cargo -p. Use -p [email protected].

  • Local server: run target/debug/inbuxa in a container, with the stalwartlabs/stalwart:v0.16.22 image as a Debian runtime only:

    docker run -d --name inbuxa-dev --entrypoint /usr/local/bin/inbuxa \
      -v $PWD/target/debug/inbuxa:/usr/local/bin/inbuxa:ro \
      -v $DIR/etc:/etc/inbuxa -v $DIR/data:/var/lib/inbuxa \
      -e INBUXA_RECOVERY_ADMIN=admin:<password> \
      -p 127.0.0.1:18080:8080 stalwartlabs/stalwart:v0.16.22 \
      --config /etc/inbuxa/config.json
    

    An empty etc starts it in bootstrap mode. x:Bootstrap/set over JMAP (serverHostname, defaultDomain, requestTlsCertificate: false) returns the permanent admin. Restart it without the recovery admin variable, then create tenants, domains and accounts over JMAP (x:Tenant, x:Domain, x:Account with @type: User). Delete the container and its folders afterwards.

  • Never print or commit a password. Generate test passwords, keep them in files under target/, and remove them along with the test server.

  • Don't touch INBUXA's production server or anything on the hosts. This work is local.

Starting prompt

For the new session, pasted as its first message:

You're implementing multi-tenancy in inbuxa-server, the local AGPL fork of Stalwart in this folder. Read docs/spec/handoff/multi-tenancy.md and follow it exactly, starting with its clean-room rules, then docs/spec/features/multi-tenancy.md. Don't read anything the brief rules out. If the spec doesn't answer something, stop and ask me.