This section documents the business capabilities the home lab provides — what each capability is for, who it serves, and the rules it operates under — independent of how it is implemented.
This is the multi-page printable view of this section. Click here to print.
Business Capabilities
- 1: Self-Hosted Application Platform
- 1.1: User Experiences
- 1.1.1: Host a Capability
- 1.1.2: Migrate Existing Data Into a Newly-Provisioned Tenant
- 1.1.3: Move Off the Platform After Eviction
- 1.1.4: Operator-Initiated Tenant Update
- 1.1.5: Platform-Contract-Change Rollout
- 1.1.6: Stand Up the Platform
- 1.1.7: Tenant-Facing Observability
- 1.2: Architecture Decision Records
- 1.2.1: [0001] Cross-Environment Topology
- 1.2.2: [0002] Tenant Workload Placement Policy
- 1.2.3: [0003] Definitions Repository & Tracked-Changes Substrate
- 1.2.4: [0004] Rebuild Orchestrator & Phase Model
- 1.3: Business Requirements
- 1.4: Technical Requirements
- 2: Self-Hosted Personal Media Storage
- 2.1: User Experiences
- 2.1.1: Bulk Import from a Prior Provider
- 2.1.2: Delete Content and Leave
- 2.1.3: Join as an Invited User
- 2.1.4: Receive Shared Content
- 2.1.5: Share Content
- 2.1.6: Upload Content
- 2.1.7: View and Organize Content
1 - Self-Hosted Application Platform
One-line definition: Provide a reproducible, operator-controlled platform on which the operator’s other capabilities run by default, so that no capability has to depend on a vendor-specific hosting solution to be delivered.
Purpose & Business Outcome
What business outcome does this capability deliver? Why does it exist?
This capability exists so that the operator’s other capabilities (e.g. self-hosted personal media storage) have a well-defined, reproducible place to run that the operator controls end-to-end, instead of each capability independently choosing a vendor (e.g. a hosted Plex provider, a hosted Minecraft provider, a hosted Nextcloud provider). The outcomes it delivers, in order of importance:
- Default hosting target for the operator’s capabilities. Any capability the operator defines should be able to run here, so that “where does this run?” is a solved question rather than re-litigated per capability.
- Reproducibility. The platform itself can be rebuilt from its definitions; it is not a snowflake. A total loss does not mean a permanent loss of the platform.
- Independence from hosting vendors. The operator is not locked into any single provider’s product roadmap, pricing, or terms for the things their capabilities depend on.
- A coherent place to invest infrastructure effort. Improvements (resiliency, observability, backup) made once at the platform level benefit every tenant capability, instead of each capability re-solving them.
When these outcomes conflict: tenant adoption beats reproducibility (a perfect platform with no tenants is a failure); reproducibility beats vendor independence (a platform that can’t be rebuilt is worse than one that uses some vendor components); vendor independence beats minimizing operator effort.
Stakeholders
- Owner / Accountable party: The operator. Sole accountable party for the platform existing, running, and continuing to run.
- Primary actors (initiators): Capability owners — currently the operator wearing a different hat — who bring a capability to the platform to be hosted, or change what an already-hosted capability needs.
- Secondary actors / consumers: The tenant capabilities themselves, while running, consume platform services (compute, storage, network, identity, backup, observability).
- Affected parties (impacted but not directly involved): End users of the tenant capabilities (e.g. family and friends using self-hosted personal media storage). They never interact with the platform directly, but a platform outage or data loss directly affects them.
Triggers & Inputs
What initiates the capability, and what information must be available?
- Triggers:
- A capability owner brings a new capability to be hosted.
- A capability owner changes the requirements of an already-hosted capability (more storage, new external endpoint, etc.).
- The operator stands up the platform from scratch (initial build or full rebuild after loss).
- The operator performs routine maintenance on the platform.
- A tenant capability’s components fall behind what the platform supports and need to be updated.
- Required inputs:
- From the capability owner: the capability packaged in the form the platform accepts, a declaration of its resource needs (compute, storage, network reachability), and its availability expectations.
- For tenants whose end users need to authenticate: either use of the platform-provided identity service, or a declared decision to bring their own.
- Preconditions:
- The operator has authorized the capability to run on the platform (no self-onboarding by tenants — the operator is the only person making this decision).
- The capability accepts the platform’s contract (see Business Rules).
Outputs & Deliverables
What does the capability produce? What changes in the world after it runs?
- Direct outputs: For each tenant capability, the platform provides:
- Compute — a place for the application to run.
- Persistent storage — durable storage for the application’s data.
- Network reachability — both internal (between tenants) and external (reachable by the tenant’s end users).
- Identity & authentication for end users — available to any tenant that wants it; tenants may opt to bring their own.
- Backup and disaster recovery — of tenant data, to a standard the platform defines.
- Observability — the operator can tell whether each tenant is up and healthy without the tenant having to instrument that itself.
- Downstream effects / state changes:
- The operator’s capabilities have a default answer to “where does this run?” and stop being individually coupled to vendor choices.
- Investments in resiliency, backup, and observability accrue across all tenants instead of being repeated per capability.
- The operator accumulates operational knowledge of one platform rather than fragmented knowledge of many vendor products.
Business Rules & Constraints
- Default hosting target. All capabilities defined in this repo are expected to run on the platform unless explicitly exempted. A capability owner may choose to host elsewhere, but the platform is the default and the burden of justification is on opting out.
- Operator-only operation. Only the operator operates the platform and has administrative access to it. There are no co-operators and no delegated administration. A designated successor (see Operator succession) holds sealed/escrowed emergency credentials but does not exercise them while the operator is active — there is no shared day-to-day administration and no routine successor access.
- Operator skill development is incidental, not an outcome. The operator may personally learn from building and running the platform, but skill development is not a stated outcome of this capability and must not influence buy-vs-build trade-offs. Those trade-offs are judged on convenience, resiliency, and cost only — “I want to learn this” is not, on its own, a valid reason to choose build over buy at the capability level.
- Tenants must accept the platform’s contract. To be hosted, a tenant must be packaged in the form the platform accepts, declare its resource needs up front, and accept the platform’s availability characteristics. A tenant that needs guarantees stronger than the platform offers must host elsewhere.
- Eviction is allowed when needs and capabilities diverge. The platform may decline to continue hosting a tenant whose requirements it cannot meet (e.g. specialized hardware, regulatory constraints, an availability target the platform does not offer). However, where the divergence is merely that the tenant’s components have fallen behind what the platform supports, the platform works with the tenant to bring them current rather than evicting.
- Eviction threshold. A tenant is evicted when accommodating it would either push routine operation sustainably above twice the Operator maintenance budget KPI, or break the Reproducibility KPI (e.g. requires manual snowflake configuration that cannot be captured as definitions). Either condition alone is sufficient grounds for eviction. The numeric thresholds are whatever those KPIs currently say; this rule is not restated in absolute hours so it cannot drift from them.
- Identity service honors tenant credential-recovery rules. Any identity implementation the platform offers to tenants must be capable of honoring a “lost credentials cannot be recovered” property (Signal-style), because at least one tenant (self-hosted personal media storage) requires it. An identity option that cannot honor this property is not eligible to be the platform-provided identity service.
- Operator succession. The platform must support both (a) on-demand exportable archives so each tenant’s users can retrieve their own content without operator involvement while the platform is healthy — users are expected to pull these proactively (and may schedule periodic pulls), since on-demand export is only available when the platform is up — and (b) a designated successor operator who holds the credentials and runbook needed to keep the platform running if the primary operator becomes unavailable. Successor credentials are sealed/escrowed (e.g. via a password-manager handoff or physical envelope) and not used for routine operation; takeover is a discrete event triggered by operator unavailability, not ongoing shared administration. The two mechanisms are complementary: exports preserve user data even if no successor takes over; a successor preserves continuity of the platform itself. If the platform is down and no successor takes over, only previously-pulled exports survive — this is the accepted trade-off.
- The platform may span public and private infrastructure. “Self-hosted” means the operator controls the platform end-to-end, not that every component runs on hardware the operator owns. Public-cloud components are allowed where the operator retains control of configuration, data, and the ability to leave.
- No direct end-user access to the platform. End users of tenant capabilities reach the tenant, not the platform. The platform has no notion of “end users” of itself; its consumers are tenant capabilities (and behind them, the operator).
- Cost is secondary to convenience and resiliency. Because there is one operator, added cost is acceptable when it buys meaningful convenience or resiliency. Cost should still be minimized where it does not cost convenience or resiliency.
- The capability evolves with its tenants. When a tenant capability needs something the platform does not yet provide, the default response is to update this capability’s definition (and the platform) rather than push the requirement back onto the tenant.
Success Criteria & KPIs
- Tenant adoption. Every implemented capability defined in this repo runs on this platform. A capability is “implemented” when it is deployed and serving its intended users in production — distinct from “defined” (a capability doc exists) and “designed” (a technical design exists but nothing is running). Only implemented capabilities count toward this KPI; defined-or-designed-only capabilities are neutral, neither success nor failure. An implemented capability that runs elsewhere counts negatively against this KPI: either the platform did not meet the tenant’s needs, or the tenant was never asked to use it.
- Reproducibility. The platform can be stood up from its definitions in at most 1 hour, starting from no platform at all. This is the operational form of “reproducible” — if it takes longer than that, the platform is a snowflake regardless of how much of its config is in version control.
- Operator maintenance budget. Routine operation of the platform takes no more than 2 hours per week of the operator’s time. If maintenance regularly exceeds this, the platform is consuming more attention than it earns and must be simplified, not grown.
- Cost stays proportional to value. Total operating cost remains within what the operator considers acceptable given the convenience and resiliency it delivers. There is no fixed dollar target; the test is whether the operator would still choose to run it knowing the bill.
Out of Scope
- Hosting for anyone other than the operator’s own capabilities. The platform does not offer hosting to third parties, the public, or family/friends directly. Family and friends reach the platform only as end users of a tenant capability (e.g. via self-hosted personal media storage), never as platform users.
- Dictating the implementation. “Homelab,” “Kubernetes,” and any specific stack are possible implementations of this capability, not part of its definition. The capability is satisfied by anything that meets its rules and KPIs.
- A specific availability or performance SLA. The platform offers whatever availability its current implementation can deliver within the operator’s maintenance budget. Tenants needing stronger guarantees host elsewhere (per Business Rules).
- End-user-facing features of tenant capabilities. Photo viewing, game server gameplay, document editing, etc. are tenant concerns, not platform concerns.
- Multi-operator administration, role delegation, or self-service onboarding. Explicitly excluded by the operator-only rule.
Open Questions
None at this time.
1.1 - User Experiences
This section documents the user experiences for the Self-Hosted Application Platform capability — the end-to-end journeys taken by the actors named in the parent capability’s Stakeholders, in pursuit of the outcomes the capability promises.
1.1.1 - Host a Capability
One-line definition: A capability owner brings a fully-designed capability onto the platform, gets it provisioned and live, and continues to evolve its needs over time.
Parent capability: Self-Hosted Application Platform
Persona
The actor here is a capability owner — one of the people named in the parent capability’s Primary actors. Although the capability doc notes this is currently the operator wearing a different hat, this UX is written as if the capability owner were a separate person from the platform operator. The role boundary is treated as real: there is an interface, a handoff, and a contract between them.
- Role: Capability owner. They have just finished defining one of the operator’s capabilities — its UX docs and its tech design are both complete. The tech design picked this platform as the host.
- Context they come from: They are not building the platform; they are a customer of it. They arrive with a capability doc, a tech design that calls out which components must run on the platform, and (ideally, but not strictly required) a mapping from those components to specific platform offerings.
- What they care about here: Getting their capability running on a controlled, reproducible substrate, declaring its needs once, understanding what they are signing up for, and having a clear path to change those needs later — without onboarding becoming a multi-day project for either side of the handoff.
Goal
“I want my capability running on the platform — with its compute, storage, network, and identity needs declared once, the platform’s contract understood, and a clear path to update those needs later — and I want it to stay running healthily as my capability evolves.”
This is a lifecycle goal, not just an onboarding one: the change-later branch lives in the same journey as the initial onboarding because it shares the same persona, surface, and contract.
Entry Point
The capability owner arrives at this experience having just finished the tech-design phase of their capability. Specifically:
- Their capability’s UX docs are complete.
- Their tech design is complete and explicitly designates this platform as the host for the components that need to run somewhere.
- Their decision about whether to use platform-provided identity or bring their own has already been made and recorded in the tech design itself — it is not a fresh question at onboarding.
What they have in hand: the capability doc and the tech design. Nothing else is required. A tech design that already names specific platform offerings per component is nice; one that doesn’t can have those gaps filled during onboarding.
Their state of mind depends on what they’re asking for:
- Fully confident if every component in their tech design maps to an offering the platform already provides.
- Semi-confident if some component requires something the platform may or may not be able to support (e.g. their capability needs GPU compute, which the platform may never provide because no GPUs are installed and buying them is out of scope).
Journey
The capability owner’s journey is a single end-to-end flow with three branches that can occur during operator review (approved as-is, new-offering needed, declined) and one re-entry loop for changing requirements after going live.
1. File an “onboard my capability” issue on GitHub
The capability owner opens an issue against the infra repo using the onboard my capability issue type. GitHub issues are the only channel for engaging the platform — there is no self-service portal and no other front door — and this is the issue type for onboarding. They link or attach the capability doc and the tech design.
What they perceive: the issue is filed, and now they wait. There is no response-time guarantee — this is personal-scale, async by default.
2. Operator review on the issue
The operator reviews the tech design with a deliberately narrow scope:
- Does each platform-hosted component align with an existing platform offering?
- Are there any components that would require a new platform offering to be added?
What the capability owner perceives: clarifying questions appear as comments on the issue, and possibly a meeting if the operator deems it necessary. They answer the questions in-thread.
3. Resolution — one of three branches
3a. Approved as-is. The operator comments “approved” on the issue. That comment is the moment the capability owner knows hosting is real. There is no separate contract-acceptance step at this point: the contract was accepted by virtue of the tech design already conforming to it (declared resource needs, identity choice, packaging, availability expectations).
3b. New offering needed. The operator agrees the right answer is to add a new platform offering to support the capability, and that the offering is still within the platform’s intended scope — meaning the platform can add it while keeping the offering reproducible within the parent capability’s Reproducibility KPI and routine operation within the Operator maintenance budget KPI. The operator does not commit to a timeline. The capability owner waits. While they wait, there is nothing for them to do on their side. Eventually the operator returns and the journey resumes at step 3a.
3c. Declined — host elsewhere. The operator closes the issue with a comment explaining why the request cannot be supported. That can be because it is simply impossible (e.g. the platform will never have GPUs because the hardware cannot be added), or because it is only technically possible and would require the platform to grow into an offering the operator does not want to carry as routine scope — specifically, one the platform could not keep reproducible within the parent capability’s Reproducibility KPI or operate within the Operator maintenance budget KPI. The capability owner now knows this capability has to be hosted somewhere else; the journey ends here.
4. Hand off packaged artifacts
For each component in the tech design that needs to be deployed, the capability owner provides a packaged artifact in the form the platform accepts. The capability owner does the packaging themselves; they do not hand over raw source for the operator to package.
What they perceive: they post or link the artifacts on the issue and wait.
5. Wait while the operator provisions
While the operator is actually wiring up compute, storage, networking, identity, backup, and observability for the new tenant, the capability owner does nothing. They are not pinged for DNS choices or secrets. They simply wait until the operator asks them to test.
6. Test on request
The operator comments asking the capability owner to test the deployed capability. The capability owner exercises it however they would normally validate that their capability works (this is their judgment — the platform doesn’t prescribe a test plan).
- If something is wrong (the deployment doesn’t work right, networking can’t reach it, an artifact failed to deploy as-given), the capability owner comments on the issue and the two iterate back-and-forth in comments until it works.
- If everything works, the capability owner says so on the issue.
7. Operator closes the issue
The operator closes the onboarding issue. The capability is now live on the platform.
8. Change-later loop (re-entry)
When the capability owner needs something different — more storage, a new external endpoint, a new component, a routine version bump, retirement of a component — they file a different issue type: modify my capability (distinct from the onboarding type, and the distinction is meaningful to the capability owner because the operator’s review scope differs).
Operator review on a modify issue covers only the delta, not a full re-evaluation. The platform contract is evergreen — the capability owner does not re-accept it on each modification. If the platform’s own contract changes, the operator is responsible for communicating the change ahead of time and migrating existing tenants; it is never sprung on the capability owner during a modify request.
The flow from issue → review → branches → artifact handoff → test → close repeats.
Flow Diagram
flowchart TD
Start([Tech design complete & names this platform]) --> File[File 'onboard my capability' issue on GitHub]
File --> Review[Operator reviews tech design:<br/>alignment to offerings + new-offering needs]
Review --> Decision{Outcome}
Decision -->|Approved as-is| Approved[Operator comments 'approved']
Decision -->|New offering needed| Wait[Wait — no timeline guarantee]
Decision -->|Declined| Decline[Issue closed with explanation —<br/>host elsewhere. Journey ends.]
Wait --> Approved
Approved --> Handoff[Capability owner hands off<br/>packaged artifacts on the issue]
Handoff --> Provision[Wait while operator provisions]
Provision --> Test[Operator asks capability owner to test]
Test --> Works{Works?}
Works -->|No| Iterate[Comment back-and-forth on the issue]
Iterate --> Test
Works -->|Yes| Close[Operator closes the issue —<br/>capability is live]
Close --> Live((Hosted))
Live -->|Needs change later| Modify[File 'modify my capability' issue]
Modify --> ReviewDelta[Operator reviews delta only;<br/>contract is evergreen]
ReviewDelta --> Decision
Live -->|Operator initiates eviction| Eviction[Operator raises eviction issue<br/>with eviction date — see Edge Cases]Success
When the onboarding issue closes, the capability owner walks away with:
- Their capability is running on infrastructure they trust to be reproducible and operator-controlled.
- The operator knows exactly what they signed up to host — needs were declared in the tech design and reviewed before approval.
- A known, low-friction path back when needs change: file a modify my capability issue and run the same loop.
- No surprises: there is no hidden ongoing obligation on their side beyond filing issues for changes.
For change-later iterations, success looks the same in miniature: the delta is reviewed, deployed, tested, and closed without re-litigating the entire capability.
Edge Cases & Failure Modes
- Test step fails after provisioning. Capability owner sees their capability isn’t working post-deploy. Experience-level handling: the issue stays open and the two iterate via comments until the deployment works. The journey doesn’t reset to the start; it loops between test and operator action.
- Operator goes silent / issue stalls. There is no response-time guarantee, so some waiting is normal. The signal that the silence has gone on too long is not a timer; it is the capability owner explicitly commenting that they are withdrawing the request and hosting elsewhere because they can no longer wait (or closing the issue saying so). Experience-level handling: that outcome is recorded on the issue itself and counts as a lost tenant against the parent capability’s Tenant adoption KPI. When the operator returns, the response is to acknowledge the loss in-thread and close the issue if it is still open — not to let the thread silently rot.
- Handed-off artifact is broken or undeployable. Symmetric with the test-fails case: comment back-and-forth on the issue until a working artifact is in place.
- New offering requested but no commitment. The capability owner’s request to add a new offering is accepted in principle but with no timeline. They wait indefinitely. If they cannot wait, they say so on the issue and host elsewhere; that is a tracked Tenant adoption KPI loss, not invisible churn.
- Capability is evicted later. This is operator-initiated, not capability-owner-initiated, so it is not a step inside this journey. From the capability owner’s perspective: at some point the operator opens an eviction issue tagging them and naming the eviction date. The capability owner now knows they must move off the platform by that date. Eviction is governed by the parent capability’s Eviction threshold rule (the request would push routine maintenance sustainably above 2× the maintenance budget, or break reproducibility).
- Operator-driven update because tenant components fell behind. Out of scope for this UX — see Out of Scope.
Constraints Inherited from the Capability
This UX must respect the following items from the parent capability’s Business Rules and Success Criteria — by name, so future readers can trace the lineage:
- Operator-only operation. There is no self-service onboarding flow. The journey’s only engagement surface is a GitHub issue the capability owner files, which the operator personally services. No co-operator or delegated administration appears anywhere in the journey.
- Tenants must accept the platform’s contract. Contract acceptance is implicit in the tech-design submission: the design declares resource needs, identity choice, packaging form, and availability expectations conforming to the platform’s contract. There is no explicit “I accept” gate — the design is the acceptance.
- Identity service honors tenant credential-recovery rules. Whichever identity option is named in the capability owner’s tech design must be one the platform actually offers. The platform-provided identity service must be capable of honoring “lost credentials cannot be recovered.” If a capability needs that property and bring-your-own is chosen, it is the capability owner’s responsibility to honor it themselves.
- Eviction threshold. The operator may raise eviction when routine accommodation would exceed 2× the operator-maintenance-budget KPI or break the reproducibility KPI. This UX surfaces eviction only as an external operator-initiated event affecting the capability owner — see Edge Cases.
- The capability evolves with its tenants. The “new offering needed” branch in step 3 is the operationalization of this rule: the default response when a tenant needs something the platform doesn’t yet provide is to consider expanding the platform, not to refuse the tenant. But the operator is not obligated to grow the platform without bound. A request is declined once satisfying it would require a new ongoing offering the platform could not keep reproducible within the Reproducibility KPI or operate within the Operator maintenance budget KPI, even if the offering is technically buildable.
- No specific availability or performance SLA. The journey does not include any negotiation of availability targets — tenants accept whatever the platform’s current implementation offers. A capability owner needing stronger guarantees should not have arrived here (their tech design would have picked a different host).
- KPI: Tenant adoption. A capability owner who explicitly gives up on onboarding because the operator stayed silent too long is counted as a lost tenant, not waved away as “they changed their mind.” The signal is the GitHub issue itself: they say they are hosting elsewhere because waiting no longer works for them. The response is to leave that loss recorded in-thread and close the issue, so the KPI reflects what actually happened.
- KPI: 1-hour reproducibility. Implication for this UX: provisioning during step 5 must be done by running the platform’s existing definitions, not by the operator hand-rolling per-tenant snowflake configuration. If onboarding requires bespoke manual config that cannot be captured as definitions, the platform itself has fallen out of compliance with this KPI — and the right response is to update the platform’s definitions, not to tolerate the snowflake.
- KPI: 2-hr/week operator maintenance budget. Implication for this UX: change-later iterations (step 8) must remain quick enough that running them does not eat the operator’s weekly budget across all hosted tenants. A tenant whose modify requests routinely cost disproportionate operator time crosses into the eviction-threshold rule. The same KPI also bounds the admission of new offerings: “technically possible” is still a decline if the resulting routine platform scope would no longer fit inside this budget.
Out of Scope
- Data migration of an existing tenant. Bringing data from a prior vendor or local install into the newly-provisioned tenant is a separate UX, not covered here. This UX is strictly about provisioning the capability on the platform.
- Operator-initiated tenant updates (“your component has fallen behind”). When the operator notices a tenant’s components have aged out of platform support, the operator initiates the conversation — that is a different journey with the operator as the primary actor and the capability owner as the responder. It belongs in its own UX doc.
- Running-tenant observability for the capability owner. This onboarding journey provisions observability as part of bringing the tenant live, but it does not cover the later “is my thing healthy right now?” monitoring journey itself. That ongoing experience belongs in Tenant-Facing Observability, not here.
- Platform-side standup or rebuild. The operator standing up the platform from scratch is one of the parent capability’s other triggers, not this UX.
- The capability owner’s tech-design phase. The decision to use this platform was made before this journey starts. How that decision is made (build vs. buy, host-here vs. host-elsewhere) is a tech-design concern, not a hosting-UX concern.
Open Questions
None at this time.
1.1.2 - Migrate Existing Data Into a Newly-Provisioned Tenant
One-line definition: A capability owner whose capability is already onboarded and running on the platform brings their existing end-user data over from the prior host by handing off a one-time migration process for the platform to run.
Parent capability: Self-Hosted Application Platform
Persona
The actor here is the same capability owner described in Host a Capability. They are not a different role for this journey — they are mid-lifecycle, having already completed onboarding, and now coming back to deal with one specific concern: their pre-existing data.
- Role: Capability owner. Their capability is already onboarded and live on the platform — compute, storage, network, identity, observability are all provisioned and running per the closed onboarding issue. The tenant is empty: no end-user data is in it yet.
- Context they come from: Their capability has historical data living somewhere else — on a vendor (e.g. a hosted Plex provider), on a local install, on a previous self-hosted setup. End users are still on that old host. The capability owner is running the new tenant and the old host concurrently during this period; cutting end users over is their concern, deliberately separate from this UX.
- What they care about here: Getting their existing data into the new tenant intact, so that when they decide to cut end users over, the new tenant is not a fresh-start regression. They want to do this with a defined, repeatable mechanism rather than ad-hoc — the operator’s 2hr/week maintenance budget depends on migrations not becoming bespoke projects.
Goal
“I want my existing end-user data moved from my old host into my new tenant on the platform — using a migration process I wrote, run by the platform on my behalf — so that when I cut my users over (on my own schedule), the new tenant has everything they expect.”
This is a one-shot goal per migration: when the data has landed and the capability owner has validated it, the migration job is torn down. There is no ongoing sync.
Entry Point
The capability owner arrives at this experience after Host a Capability has fully completed for their tenant — onboarding issue closed, tenant live and empty. They have a parallel, still-running deployment of their capability on a prior host (vendor or self-managed), and they have written a migration process — a one-time job that reads from the prior host and writes into the new tenant via the new tenant’s normal interfaces — packaged in the form the platform accepts (same packaging as any other capability component).
What they have in hand:
- A reference to the closed onboarding issue (so the destination tenant is unambiguous).
- A packaged migration process artifact.
- Credentials needed by the migration process to talk to the old host.
- A rough sense of resource needs for the migration job (compute, network egress to the old host, expected runtime).
State of mind: pragmatic. They know this is bespoke to their capability — the platform is providing a runner for a process they wrote, not a magic mover.
Journey
1. Register old-host credentials with the platform secret management offering
Before filing the issue, the capability owner registers any credentials their migration process needs (to read from the old host) with the platform’s secret-management offering. The migration process artifact will reference these by name; the secrets themselves do not appear on the issue.
What they perceive: standard usage of the platform’s secret-management offering. This step exists outside the issue and is the capability owner’s responsibility to complete before handoff.
2. File a “migrate my data” issue on GitHub
The capability owner opens an issue against the infra repo using the migrate my data issue type — distinct from onboard my capability and modify my capability because the operator’s review scope and the lifecycle (one-shot, torn down on completion) differ. The issue contains:
- A link to the closed onboarding issue (identifying the destination tenant).
- A description of the source (old host, format, rough data volume).
- The packaged migration process artifact (or a link to it).
- A declaration of the migration job’s resource needs (compute, storage, network reachability — including egress to the old host), including any temporary migration-only spikes beyond the tenant’s steady-state footprint, and the names of the secrets it expects to read from the platform’s secret-management offering.
- A declaration of the migration process’s re-run contract: whether it is safe to run against an already-populated destination tenant, or whether the destination must be wiped / empty before each run.
What they perceive: the issue is filed. They wait, async, just like onboarding.
3. Operator review on the issue
The operator reviews the migration request with a deliberately narrow scope — the delta the platform is being asked to support for this one-shot job. Specifically, the operator confirms with the capability owner:
- Resources: the migration’s peak temporary footprint — the destination tenant’s steady-state compute and storage footprint plus any migration-only spike declared on the issue — is no more than 2x the destination tenant’s steady-state compute and storage footprint, and it fits within the platform’s currently available migration-process capacity. If either compute or storage exceeds that threshold, the operator rejects the request as written and asks the capability owner to split the migration into smaller runs, reduce the spike, or resize the tenant first via
modify my capability. - Network: the migration job has the egress reachability it needs to talk to the old host, and ingress to the destination tenant’s storage interfaces.
- Credentials: the named secrets are registered and the migration process is wired to read them correctly.
- Re-run contract: the issue is explicit about whether retries or later top-up migrations can run against existing data, or whether each run requires an empty / wiped destination.
What the capability owner perceives: clarifying questions appear as comments on the issue. They answer in-thread. There is no review of the migration process’s internal logic — that is the capability owner’s domain. The operator is reviewing what the platform must provide to run it, not whether it does the right thing.
4. Operator onboards and starts the migration job
Once the review converges, the operator wires up the one-time migration job using the platform’s migration-process offering and starts it. The capability owner does nothing during this step — same as the provisioning step in host-a-capability. They simply wait for the migration job to be running.
Concurrent migrations across different tenants are supported. The capability owner should not expect exclusive use of the migration-process offering; if other tenants are migrating at the same time, their own journey still looks the same.
5. Capability owner observes the running job
While the migration job runs, the capability owner watches it through the platform’s observability — the same observability surface every other platform offering exposes to its tenant. They can see whether the job is making progress, whether it has errored, and whatever signals their migration process emits.
What they perceive: visibility into their own job, on their own time. There is no operator handholding during the run. Long migrations (hours, days) are normal — there is no SLA, just observability.
6. Operator reports the job’s terminal state on the issue
When the migration job finishes — successfully or with an error — the operator reports the terminal state on the issue and asks the capability owner to validate.
7. Resolution — one of two branches
7a. Success — capability owner validates data presence. The capability owner verifies the data landed correctly, per their capability’s own definition of correct (open the app, check counts, spot-check records — their judgment, not the platform’s). When they’re satisfied, they say so on the issue.
7b. Failure — capability owner provides the plan for next steps. If the migration job errored, or if validation reveals the data is incomplete or wrong, the capability owner is responsible for deciding what happens next — because this is their data and their migration process. Possible plans they may propose on the issue:
- Wipe the destination tenant’s storage and re-run with a fixed migration process (re-handoff a new artifact).
- Resume from where it failed (only viable if their migration process supports this).
- Accept the partial state and run a follow-up migration for the remainder.
- Abandon this migration attempt entirely.
The platform does not prescribe a recovery model. The operator executes whatever next-step plan the capability owner provides, looping back through the appropriate earlier step (re-handoff → re-review → re-run, or just re-run).
8. Operator tears down the migration job and closes the issue
Once the capability owner confirms validation success, the operator tears down the one-time migration job (it is not retained — re-running later means filing a fresh migrate my data issue) and closes the issue.
The new tenant now holds the migrated data. Cutting end users over from the old host to the new tenant is the capability owner’s separate concern, outside this UX.
Flow Diagram
flowchart TD
Start([Onboarding complete; tenant live & empty]) --> Secrets[Register old-host credentials with<br/>platform secret-management offering]
Secrets --> File[File 'migrate my data' issue<br/>linking the closed onboarding issue]
File --> Review[Operator confirms resources,<br/>network, and credentials with CO]
Review --> Run[Operator onboards and starts<br/>the one-time migration job]
Run --> Observe[CO observes job via platform observability]
Observe --> Terminal[Operator reports terminal state on issue]
Terminal --> Validate{CO validates data?}
Validate -->|Yes — data is present and correct| Teardown[Operator tears down migration job<br/>and closes the issue]
Validate -->|No — failure or incomplete data| Plan[CO provides plan for next steps]
Plan --> Branch{Plan}
Branch -->|Re-handoff fixed artifact| Review
Branch -->|Re-run as-is| Run
Branch -->|Abandon| Teardown
Teardown --> Done((Data migrated;<br/>cutover is CO's concern))Success
When the issue closes, the capability owner walks away with:
- Their existing end-user data sitting inside the new tenant, validated by them against their own capability’s definition of correctness.
- A clean platform state: the one-time migration job is torn down, leaving only the tenant and its data behind.
- Confidence that when they decide to cut their end users over, the new tenant will not look like a regression.
- A known, repeatable path if they ever need to migrate again (file another
migrate my dataissue and declare the process’s re-run contract again).
Edge Cases & Failure Modes
- Migration job errors out partway, leaving partial data in the tenant. Experience-level handling: the operator reports the error on the issue; the capability owner provides the plan (wipe-and-retry, resume, accept partial, abandon). The platform does not auto-clean — the data belongs to the capability owner and they decide what to do with it.
- Validation reveals data is wrong even though the job reported success. Same as above — capability owner provides the plan. This is treated identically to a job-level failure from the journey’s perspective.
- Migration takes far longer than the capability owner expected. Experience-level handling: there is no SLA, and the capability owner can see what is happening through the platform’s observability. They can decide whether to let it run or to file a plan to abort and re-approach.
- Migration job needs more resources than declared (storage too small in the tenant, more compute, etc.). Experience-level handling: temporary migration-only spikes are allowed only if declared up front and approved during review, and approval is bounded by the step-3 rule that the migration’s peak temporary footprint can be at most 2x the destination tenant’s steady-state compute and storage footprint. If the real job exceeds what was declared, the operator surfaces this on the issue; the capability owner may need to file a separate
modify my capabilityissue against the destination tenant first (e.g., to enlarge storage), split the migration into smaller runs, or re-file the migration with a corrected declaration. The two issues are explicitly distinct because they touch different review scopes. - Old host becomes unavailable mid-migration (vendor outage, account suspended, etc.). Experience-level handling: the migration job will fail; same as any other failure — capability owner provides the plan. The platform makes no attempt to resume on the capability owner’s behalf.
- Capability owner registered the wrong secrets, or the migration process can’t authenticate to the old host. Same as any other failure mode — surfaces during the run, capability owner adjusts and the issue iterates.
- Another tenant is migrating at the same time. Experience-level handling: no special branch. Concurrent migrations are part of the offering; the capability owner still files the same issue, waits through the same review, and observes only their own job.
- Capability owner wants to re-run the migration months later (e.g., to top up data accumulated on the old host since the first migration). The experience is still: file a fresh
migrate my dataissue. The previous migration job is gone; the new one is a separate one-shot, and the capability owner must explicitly declare whether the process is safe against existing data or whether the destination must be wiped first.
Constraints Inherited from the Capability
This UX must respect the following items from the parent capability’s Business Rules and Success Criteria — by name, so future readers can trace the lineage:
- Operator-only operation. As with
host-a-capability, the only engagement surface is a GitHub issue the capability owner files; the operator personally services it. The capability owner has no direct access to start, stop, or observe migration jobs except through the platform’s observability surface, which is itself an offering the operator runs. - Tenants must accept the platform’s contract. The migration process is packaged in the same form the platform accepts for any tenant component — the contract does not relax for migration. A migration process that cannot be packaged this way cannot be run by the platform. Declaring the process’s resource needs and re-run contract up front is part of that contract.
- The capability evolves with its tenants. The existence of a migration-process offering — a platform-provided one-shot-job runner with the platform’s standard observability — is itself an instance of this rule. The platform extends to support a need (migrating in pre-existing data) that tenants have, rather than refusing tenants whose data already exists somewhere.
- Identity service honors tenant credential-recovery rules. Indirectly relevant: if the migration includes user-account or credential references from the old host, the capability owner’s migration process must produce data that respects whatever identity properties their capability requires (e.g. for self-hosted personal media storage, the “lost credentials cannot be recovered” property must still hold post-migration). This is the capability owner’s responsibility, embedded in their migration process — the platform does not enforce it.
- KPI: 1-hour reproducibility. The migration offering itself must be reproducible from definitions, like every other offering. A specific migration job is per-tenant and not part of the platform’s reproducible state — it is a one-shot artifact that ceases to exist after teardown.
- KPI: 2-hr/week operator maintenance budget. A migration that demands disproportionate operator time across the issue’s review-run-iterate loop pressures this budget. Repeated failed migrations from the same capability owner — or migrations that require the operator to deeply understand the capability owner’s data to make progress — would cross into the eviction-threshold rule’s territory.
- Eviction threshold. Sustained migration friction is a possible (if unusual) path into eviction. The platform offers to run a migration process; it does not offer to write one, debug it, or shepherd a problem capability through repeated attempts.
- No specific availability or performance SLA. No SLA on migration completion either. Migrations take however long they take; the capability owner sees progress through observability and decides what to do about long-running jobs. Supporting concurrent migrations does not imply exclusive capacity or a completion-time guarantee for any one tenant’s job.
- Operator succession. The migration job’s lifespan is bounded — it exists only between steps 4 and 8 of this journey. If the operator becomes unavailable mid-migration, the successor’s takeover responsibility is to keep the platform running, not to finish in-flight migration jobs. A mid-migration tenant simply has a stalled job; the capability owner provides a plan when a successor (or recovered operator) is back.
Out of Scope
- Cutting end users over from the old host to the new tenant. This is a capability-owner concern, deliberately outside the platform’s view. The capability owner runs old + new concurrently and cuts over on their own schedule using whatever mechanisms their capability provides for end users.
- Ongoing sync or replication between the old host and the new tenant. This UX is one-shot. A capability that needs continuous sync is a different capability (and likely a different UX, if it ever exists).
- Writing or debugging the capability owner’s migration process. The platform runs what is handed to it. Logic correctness, source-format handling, schema translation, and idempotency belong to the capability owner.
- Helping the capability owner pull data out of the old host. The migration process must speak to the old host on its own. The platform does not maintain adapters or know about specific vendors.
- Validation of data correctness. Per Move Off the Platform After Eviction, the platform provides bytes faithfully but does not validate semantic correctness. The same applies in reverse here — the capability owner is the only judge of “did the data land correctly.”
- Rollback to the old host. The capability owner is already running the old host concurrently; “rollback” simply means they don’t cut over. There is no platform-side rollback because there was nothing to roll back from — end users were never on the new tenant during the migration window.
Open Questions
None at this time.
1.1.3 - Move Off the Platform After Eviction
One-line definition: A capability owner whose capability has been evicted gets their data out cleanly and walks away with no obligations and no tenant-accessible copy left on the platform once the retention window closes.
Parent capability: Self-Hosted Application Platform
Persona
The actor here is a capability owner whose capability has been evicted — a Primary actor (initiator) from the parent capability’s Stakeholders, on the way out. As elsewhere in this capability’s UX docs, the role is treated as separate from the operator’s even though today both hats are worn by the same person.
- Role: Capability owner. The party who originally onboarded a capability onto the platform via
host-a-capability, has been hosting it for some period, and is now being removed. - Context they come from: The parting is amicable. Eviction was triggered by a divergence the platform legitimately cannot meet — specialized hardware, regulatory constraints, an availability target stronger than the platform offers — not by a missed deadline in the
operator-initiated-tenant-updateflow. Negotiation over the eviction date has already happened upstream, before this UX begins. The capability owner accepts that they are leaving and has agreed to the date. - What they care about here: A clean exit. By the eviction date their capability is fully off the platform, their data is in their hands in a portable form they can verify, and nothing remains available for them to retrieve from the platform after the retention window ends. They are not asking the platform to help them figure out where to run next — that is their problem to solve.
Goal
“By the time the platform is finished with my capability, I have my data, I know it’s complete, and I have nothing left to chase down here.”
Entry Point
The capability owner arrives at this experience because the operator has filed an eviction issue against the infra repo tagging them. The issue contains exactly:
- The eviction date (already negotiated upstream — not up for renegotiation in this journey).
- The reason for eviction (so it is on the record and the parting stays amicable).
- A link to the platform’s export tooling, with documentation on how to use it and what the export shape looks like for their tenant.
That is all the issue carries. The capability owner’s state of mind is “the date is set, I know where the export tool is, I have a window of time to get my data out and walk away cleanly.”
Journey
The journey runs in three phases keyed off the eviction date: a pre-eviction window where the tenant is still live, the eviction date itself when compute and network resources go away, and a 30-day grace window where data is held in an export-only, read-only state before tenant data is permanently deleted across all tiers at day 30.
Phase A — Before the eviction date (tenant still live)
1. Read the eviction issue and the export documentation
The capability owner reads the issue, follows the link to the export tooling, and reads its documentation. They learn what the export will produce — file layout, formats, what is included, what is not — and roughly how long an export of their dataset will take to run. No back-and-forth with the operator is expected here; the issue and the docs are meant to be self-sufficient.
2. Notify their own end users
The capability owner tells their end users that the capability is going away on the eviction date — separately from the platform, on whatever channel they use with their users. The platform plays no role here; end users of a tenant capability are not visible to the platform and the platform does not communicate with them. (See No direct end-user access to the platform in Constraints.)
3. Run the export and verify it themselves
The capability owner kicks off the export using the platform’s export tool. What they perceive is an archive of their tenant’s data, produced for them to download then and there, plus a checksum/hash and total size in bytes that the platform produces alongside it. Validation that the export is complete and correct is the capability owner’s responsibility, not the platform’s. Only the capability owner knows their data well enough to say “yes, this is all of it and it is intact.” The platform offers checksum/hash and total size as the ceiling of what it can verify on the capability owner’s behalf — anything beyond that (record counts, schema integrity, business invariants) is theirs.
4. (Optional) Run the export iteratively
Because end users may still be writing data while the tenant is live, an export taken in Phase A is not necessarily the final export. The capability owner may run multiple exports across Phase A — one early to validate that the tooling produces something usable, another later to capture more recent writes. Whether they do this is their call; the platform supports it because the export tool simply runs whenever invoked. Each run is ephemeral: if they want to keep an export, they download it when it is produced. The platform does not keep a history of prior exports around for them.
Phase B — The eviction date
5. Compute and network resources are torn down; the tenant stops serving
On the eviction date the operator deprovisions the tenant’s compute, network, and other live resources. From the capability owner’s seat: the tenant is no longer reachable by their end users. The data persists, but only in an export-only, read-only state — no further writes can occur, by anyone. A comment is posted on the eviction issue confirming the cutover and the start of the 30-day retention window.
What the capability owner perceives: the issue gets a status comment, and they now know their dataset is frozen. If they had not finished extracting data before this point, they still have 30 days — but the dataset they extract from now on is the final one.
Phase C — Post-eviction (30-day retention window)
6. Run the export of record (if not already taken)
In Phase C the export tool still works, but now against a stable, read-only snapshot. For capability owners with more data than they could extract during Phase A, or for those who deliberately deferred to avoid racing live writes, this is when the definitive export is pulled. As in Phase A, the generated export artifact is ephemeral: they re-run the same export tool, get back an archive plus checksum/hash and size, and must download it when it is produced rather than assuming the platform will keep that generated file around for later pickup. If they miss that download, they can run the export tool again at any point within the 30-day retention window and validate the newly generated archive the same way they validated in Phase A.
For capability owners who already pulled what they needed in Phase A, Phase C is a safety net — “I forgot a thing, let me grab it” — rather than the main event.
7. Walk away
Once the capability owner is satisfied they have everything, they comment on the issue indicating they are done. The operator closes the issue. After 30 days from the eviction date, the platform permanently deletes the tenant’s data — both the tenant-accessible copy and any platform-held backup-tier copies — regardless of whether the capability owner ever closed the loop. No residual copy survives day 30 in any tier the platform controls. There is no “are you sure?” — the 30-day clock is hard.
Flow Diagram
flowchart TD
Start([Eviction issue filed by operator<br/>date already negotiated]) --> Read[Read issue + export tooling docs]
Read --> Notify[Notify own end users<br/>off-platform]
Notify --> ExportLive[Run export against live tenant<br/>verify checksum / size / contents]
ExportLive --> Iter{More writes expected<br/>before eviction date?}
Iter -->|Yes| ExportLive
Iter -->|No| Wait[Wait for eviction date]
Wait --> Cutover[Eviction date:<br/>compute/network torn down<br/>data → read-only<br/>comment posted on issue]
Cutover --> PhaseC{Need more data<br/>from final snapshot?}
PhaseC -->|Yes| ExportFinal[Run export against frozen snapshot<br/>download now + verify]
PhaseC -->|No, already complete| Done
ExportFinal --> Done[Comment 'done' on issue;<br/>operator closes it]
Done --> RetentionEnds([30 days post-eviction:<br/>all tenant data permanently deleted<br/>including backup-tier copies])Success
When the journey ends cleanly, the capability owner walks away with:
- A verified, complete archive of their tenant’s data, sized and checksummed by the platform, validated by them.
- A clear paper trail on the eviction issue showing the date, the reason, and confirmation that they pulled what they needed.
- Nothing left to chase down on the platform. After the 30-day window the platform permanently deletes the tenant’s data across every tier it controls — no tenant-accessible copy and no deeper backup-tier copy survives.
- An amicable ending. The operator filed the issue, the platform held the data the agreed amount of time, and the capability owner left under their own power. The relationship is intact for whatever comes next.
Edge Cases & Failure Modes
- Capability owner asks for more time after the eviction date. Hard wall. The negotiation over the eviction date happened upstream of this journey; once that date is set, it is the date. The 30-day post-eviction retention is the only post-date slack and it is fixed.
- Export takes longer than 30 days to actually run on a very large dataset. Same hard wall — the capability owner had Phase A plus 30 days of Phase C to extract; if that is not enough, they had advance warning during eviction-date negotiation and should have raised it then. The platform does not extend the retention window for slow extracts.
- Export comes back wrong (checksum mismatch, missing files, corruption visible to the capability owner). The capability owner reports the problem on the eviction issue so that thread remains the coordination record. This is the one exception to the 30-day hard wall: if the failure is shown to be in the platform’s export tooling or its data hosting, the operator pauses that tenant’s retention-window countdown for removal of tenant-accessible data until the platform-side issue is resolved and a clean export has been produced, so the capability owner can continue exporting during that pause. No separate restoration SLA is promised in this UX; the issue stays open until the capability owner can pull a clean export. Failures rooted in the capability owner’s own validation steps do not pause that retention-window countdown.
- Export tooling does not exist for this tenant’s data shape at the time of eviction. Cannot happen by design — export tooling is a core platform feature, present for every kind of data the platform hosts. If a hole is discovered, that is itself a platform bug, handled the same way as the previous bullet (eviction issue remains open, that tenant’s retention-window countdown for removal of tenant-accessible data is paused).
- Capability owner ignores the issue entirely and never extracts anything. No special handling. The 30-day clock runs, tenant-accessible data is removed, the issue is closed by the operator. The capability owner may have made themselves whole through other means (their own backups, accepting the loss); the platform does not chase them.
- End users keep hitting the tenant after the eviction date. They get whatever connection failure the underlying infra produces. The capability owner is responsible for having warned their end users; the platform does not present a “this tenant has been retired” page or otherwise communicate with end users — end users belong to the capability, and from the platform’s seat, the capability is the end user.
- Capability owner wants to come back later (re-onboard the same capability after the divergence is resolved). That is a new
host-a-capabilityjourney, not a continuation of this one. It is not blocked, but nothing about this UX preserves state to make it easier.
Constraints Inherited from the Capability
This UX must respect the following items from the parent capability — by name:
- Eviction is allowed when needs and capabilities diverge. This UX is the operationalization of the amicable form of that rule: the divergence is real (specialized hardware, regulatory constraint, availability target the platform cannot meet) and the parting is mutual. The fall-behind variant of eviction is handled separately via
operator-initiated-tenant-update. - No direct end-user access to the platform. End users of the tenant capability are not visible to the platform and are not communicated with by the platform during eviction. Notification of end users is purely the capability owner’s responsibility.
- Operator succession — on-demand exportable archives. The same export mechanism that the parent capability promises for operator-succession scenarios is what powers this journey. Export tooling is therefore not bespoke to eviction; it is a core platform feature that exists at all times for every tenant. This UX simply consumes it.
- Operator-only operation. The capability owner has no administrative access during this journey. Everything they do — running exports, leaving comments — is done through the same surfaces an end-state non-operator has. The operator is the one who deprovisions resources and closes the issue.
- Affected parties (end users of the tenant capability). End users feel this journey indirectly: their access to the capability ends on the eviction date. The platform does not surface this to them — the capability owner does, separately, on their own channels.
- KPI: 2-hr/week operator maintenance budget. Implication: this journey must not require the operator to do bespoke per-tenant work. The export tool is generic and runs on demand; the operator’s only routine touchpoints are filing the issue, posting the cutover comment, and closing the issue at the end. A tenant whose eviction would require custom export work is itself a sign the platform’s export tooling has a gap that needs fixing — handled as a platform bug, not as an operator-effort overrun.
- KPI: 1-hour reproducibility. Implication: the data formats produced by the export tool, and the way they relate to the platform’s definitions, should be expressible as part of the platform itself, not as snowflake per-tenant logic. (Standing the platform up should not require remembering “and here is the special export path for tenant X.”)
Out of Scope
- The eviction-decision journey itself. Why the operator decided to evict, and the conversation that established the eviction date, happens before this UX. By the time this UX begins, the issue is filed, the date is set, and both parties have agreed.
- The fall-behind eviction path. Eviction triggered by a missed extended date in
operator-initiated-tenant-updateis a different shape (less amicable, possibly compressed timelines). It enters a separate journey not covered here, even though the mechanics of getting data out via the export tool may overlap. - Helping the capability owner figure out where to run next. The platform does not point at alternative hosts, port the capability’s runtime, or assist with migration. The export tool produces data; the rest is the capability owner’s problem.
- Application/runtime/configuration migration tooling. Only data export is provided. Capability code, container images, configuration, secrets management at the destination — none of this is the platform’s concern.
- Re-onboarding the same capability later. If the capability owner wants to come back, that is a fresh
host-a-capabilityjourney with no special path inherited from having previously been here. - Operator’s side of this journey. This UX is written from the capability owner’s seat. The operator’s experience (filing the issue, deprovisioning on the date, posting the cutover comment, closing the issue, watching the 30-day clock) is captured here as a responder, not as a separate document.
Open Questions
None at this time.
1.1.4 - Operator-Initiated Tenant Update
One-line definition: The operator notices a hosted tenant’s components have fallen behind what the platform supports, opens the conversation, and works with the capability owner to bring them current — without evicting.
Parent capability: Self-Hosted Application Platform
Persona
The actor here is the operator — the Owner / Accountable party from the parent capability’s Stakeholders. The capability owner is a responder in this journey, not the initiator. As with host-a-capability, this UX is written as if the operator and the capability owner were separate people: the role boundary is treated as real, even though today both hats are worn by the same person.
- Role: Operator. Sole administrator of the platform; the only person who can see across tenants and notice that one of them has aged out of what the current platform offers.
- Context they come from: They have just learned that something in the platform itself must change — a cloud provider is sunsetting a service the platform depends on; a CVE has landed against a platform component; a runtime version the platform offers is being retired upstream. The change forces an update on every tenant still using the affected component.
- What they care about here: Getting affected tenants migrated with their capability owners, on a timeline driven by the real external pressure, without burning down the “we work with you, we don’t evict for fall-behind” promise — and without letting the situation drag past the point where the platform itself becomes unsafe or unsupportable.
Goal
“I want every tenant still on the falling-behind component to be moved onto what the platform now supports, on a timeline that fits the external pressure that forced this — and I want to do it by working with each capability owner rather than evicting them.”
Entry Point
The operator arrives at this experience because of a platform-level dependency event that is not under their control:
- A cloud provider has announced a sunset date for a service the platform uses.
- A CVE has been disclosed against a platform component, so the platform itself must update — and any tenant pinned to the affected component must update with it.
- An upstream runtime, library, or base image the platform offers is reaching end-of-life.
The deadline is therefore inherited from the external event, not invented by the operator. The operator’s state of mind is “I have to do this anyway; how many tenants am I dragging through it with me, and what do they each need to ship?”
What they have in hand: knowledge of which platform offering is changing, by when, and which currently-hosted tenants are using it.
There is no formal tenant-facing pending-update view ahead of this moment. If the platform ever adds an earlier deprecation or pending-update signal for capability owners, that signal would live in Tenant-Facing Observability rather than in this operator-side journey. The operator-filed issue remains the first official signal that this journey has begun.
Journey
1. File a “platform update required” issue per affected tenant
For each affected tenant, the operator opens an issue against the infra repo using the platform update required issue type. This is a distinct issue type from onboard my capability and modify my capability — the distinct type is the signal to the capability owner that this is not optional cleanup, it is a required update with a real deadline behind it.
If the same tenant is hit by two unrelated forcing events at roughly the same time, the operator opens separate platform update required issues — one per event — and cross-links them if the remediation overlaps. The forcing event, reason, and deadline stay distinct even when the same code change may help satisfy more than one thread.
The issue tags the capability owner and contains:
- What is falling behind (the specific platform offering / component / version).
- What it is being replaced by, or what the new platform-supported version is.
- The shape of the update being asked for — repackage against a new runtime, swap a dependency, rebuild against a new base, etc.
- The deadline, with the external reason for it (sunset date, CVE remediation window, EOL date).
What the operator perceives at this point: the issue is filed and the capability owner has been notified. They wait for acknowledgment.
2. Capability owner acknowledges and plans
The capability owner reads the issue, asks any clarifying questions in-thread, and indicates whether the requested shape of update is feasible within the deadline. The operator answers questions as they come.
If the capability owner needs more time than the inherited deadline allows, the conversation moves into step 4 (slack negotiation) before any artifacts are handed off. Otherwise it proceeds to step 3.
3. Run the modify inner-loop
From here the mechanics are identical to the modify my capability journey:
- The capability owner hands off updated packaged artifacts on the issue.
- The operator re-provisions against the platform’s new offering.
- The operator asks the capability owner to test.
- They iterate in comments until it works.
- The operator closes the issue.
The inner loop is the same surface; only the initiator and the issue type differ. End-user impact during the test/redeploy step is the same as a routine modify — typically a brief outage during cutover, nothing more.
4. Negotiate slack against the inherited deadline
If the capability owner cannot ship within the inherited deadline, the operator and capability owner first determine whether the external pressure leaves any safe slack at all. If it does, they negotiate an extended delivery date in the issue thread. The extension is not unbounded — the operator sets it based on how much slack the external pressure actually allows (a CVE with a known exploit allows much less slack than a vendor sunset announced 18 months out).
If the inherited deadline leaves no safe slack, the operator declines the extension and the original inherited deadline remains the operative date. The capability owner still gets the chance to ship against that date; they just do not get more time.
Whether extended or not, the date the operator and capability owner are now working against is recorded clearly on the issue. The journey then resumes at step 3.
5. Tip into eviction (after the last workable date is missed)
If the capability owner misses the operative delivery date — either the original inherited deadline when no extension was possible, or an agreed extended delivery date when one was — the operator opens a separate eviction issue (per the parent capability’s eviction journey — to be defined as its own UX) that links back to this issue for context. The eviction issue carries its own eviction date.
This update issue is then closed as superseded by the eviction. The journey ends here from the operator’s side; the capability owner’s experience continues in the Capability owner moves off the platform after eviction UX.
The decision to evict is governed by the parent capability’s Eviction threshold rule: continuing to accommodate this tenant would either push routine maintenance sustainably above 2× the operator-maintenance-budget KPI, or break the reproducibility KPI by leaving the platform stuck on a snowflake configuration to keep one tenant alive. A missed operative delivery date is the operational signal that the threshold has been crossed; it is not eviction-by-policy for being late.
Flow Diagram
flowchart TD
Start([Platform dependency event:<br/>vendor sunset / CVE / EOL]) --> File[Operator files 'platform update required'<br/>issue per affected tenant]
File --> Ack[Capability owner acknowledges<br/>and asks clarifying questions]
Ack --> Feasible{Feasible by<br/>inherited deadline?}
Feasible -->|Yes| Modify[Run modify inner-loop:<br/>artifacts → provision → test → close]
Feasible -->|No| Slack{Safe slack for<br/>extension?}
Slack -->|Yes| Extend[Negotiate extended delivery date<br/>recorded on the issue]
Slack -->|No| NoExtend[No extension available;<br/>original deadline stands]
Extend --> Modify
NoExtend --> Modify
Modify --> MetDeadline{Delivered by<br/>operative date?}
MetDeadline -->|Yes| Done([Issue closed — tenant current])
MetDeadline -->|No| Evict[Operator opens separate eviction issue,<br/>links back to this one]
Evict --> Closed([This issue closed —<br/>superseded by eviction])Success
When the issue closes cleanly, the operator walks away with:
- Every affected tenant is now running on what the platform currently supports — no stragglers pinned to the retired offering.
- The “we work with you, we don’t evict for fall-behind” promise was honored: each capability owner was given the chance to ship the update, with extension where the inherited deadline didn’t fit and safe slack existed.
- The platform is free to actually retire the old offering, since there are no tenants left on it. The external pressure that started the whole journey can now be fully addressed.
- A trail on each issue showing what was asked for, when, and what was shipped — useful the next time a similar dependency event happens.
Edge Cases & Failure Modes
- Multiple tenants affected by the same platform event. Each gets its own issue, so each capability owner sees a request scoped to their capability. The operator coordinates timelines across all of them but does not bundle them into a single thread.
- Capability owner goes silent. Same shape as silence in
host-a-capability— there is no formal SLA in either direction. Experience-level handling: the operator can grant an extension only if safe slack exists, but if silence persists past the operative delivery date, step 5 applies. - Update cannot be shipped at all (capability fundamentally incompatible with the new offering). This is functionally the same as a missed operative delivery date: the operator opens an eviction issue. The right root response, per the parent capability’s “the capability evolves with its tenants” rule, is to consider whether the platform should keep supporting the old form — but if the external pressure (CVE, hard vendor sunset) makes that impossible, eviction is the honest outcome.
- CVE with active exploit shortens the timeline aggressively. The operator may file the issue with very little slack, and the extension in step 4 may be much smaller than for a routine sunset — or unavailable entirely. The journey shape is unchanged; the deadlines just compress.
- Update reveals a new requirement that the platform doesn’t yet offer. Hand off into the
host-a-capabilitychange-later loop’s “new offering needed” branch — the platform-update issue stays open while the new offering is added, then resumes at step 3. - Multiple overlapping platform updates against the same tenant. The operator opens one issue per forcing event, even for the same tenant, so each deadline and external reason stays legible. If the remediation overlaps, the issues cross-link and the operator coordinates them together.
Constraints Inherited from the Capability
This UX must respect the following items from the parent capability — by name, so the lineage is traceable:
- Eviction is allowed when needs and capabilities diverge — but fall-behind cases work with the tenant. This UX is the operationalization of that carve-out. The default outcome is “we update together,” not “we evict.” Eviction enters this journey only via the missed-final-date branch and only as a separate, linked issue.
- Eviction threshold. A missed operative delivery date is the operational signal that continuing to accommodate this tenant would cross the 2×-maintenance-budget or reproducibility threshold. The numeric threshold lives with the KPI; this UX inherits whatever it currently is.
- The capability evolves with its tenants. Before transitioning to eviction in step 5, the operator considers whether the platform should keep supporting the older form — sometimes the right answer is to absorb the maintenance, not push the tenant forward. That choice is constrained by what the external pressure actually allows (a CVE generally rules it out; a vendor sunset announced years ahead may not).
- Operator-only operation. The operator is the only person who can see that a tenant has fallen behind, because cross-tenant visibility lives only with the operator. There is no automated tenant-side warning system surfacing this from the platform to the capability owner today.
- KPI: 2-hr/week operator maintenance budget. A tenant that routinely needs hand-holding through these updates — repeatedly missing deadlines, repeatedly needing extensions — is consuming disproportionate operator time and crosses into the eviction-threshold rule on its own merits, even before any single missed operative delivery date.
- KPI: 1-hour reproducibility. Implication for this UX: the re-provision step in the inner loop must run through the platform’s existing definitions (now updated to the new offering), not through a per-tenant snowflake patch. If the only way to keep a tenant alive is bespoke manual config, that is itself eviction-threshold material.
Out of Scope
- The eviction journey itself. When step 5 fires, the operator opens a separate eviction issue and the capability owner’s experience continues in Capability owner moves off the platform after eviction — a sibling UX, not part of this one.
- Platform-contract changes that aren’t forced by an external dependency event. When the operator decides to change the platform’s contract (retire a packaging form, alter availability characteristics) absent external pressure, that’s the platform-contract-change rollout UX, not this one. The seam: this UX is reactive (something outside the operator’s control forced the update); the contract-change UX is proactive (the operator chose to change something).
- The capability owner’s side as a primary journey. This UX is written from the operator’s seat. The capability owner’s experience of receiving and responding to one of these issues is captured here as a responder, not as a separate document — it shares enough surface with
modify my capability(artifacts, test, iterate, close) that a separate doc would mostly duplicate. - Detection of fall-behind itself. How the operator notices a tenant is on a falling-behind component (vendor announcement watching, CVE feeds, manual review) is operational detail, not part of the user experience. This UX starts at the moment the operator has decided to act.
- Tenant-facing visibility into pending platform updates before the issue is filed. Capability owners do not get an official warning surface ahead of the issue in this UX. If the platform later adds an earlier deprecation or pending-update signal, that signal belongs in Tenant-Facing Observability, not here, and does not replace issue filing as the start of this journey.
- Routine modify requests. A capability owner shipping a version bump on their own initiative is the change-later loop in
host-a-capability, not this UX.
Open Questions
None at this time.
1.1.5 - Platform-Contract-Change Rollout
One-line definition: The operator proactively changes a term of the platform’s contract — retiring an offering, changing a packaging form, altering availability characteristics — communicates that change to every affected tenant ahead of time, and migrates them all onto the new contract before the old one is retired.
Parent capability: Self-Hosted Application Platform
Persona
The actor here is the operator — the Owner / Accountable party from the parent capability’s Stakeholders. The capability owners are responders in this journey, not initiators. As with host-a-capability and operator-initiated-tenant-update, this UX is written as if the operator and the capability owners were separate people: the role boundary is treated as real even though today both hats are worn by the same person.
- Role: Operator. Sole administrator of the platform; the only person who can change the platform’s contract and the only person who can see across tenants to know which ones are affected.
- Context they come from: They have decided to change a term of the platform’s contract — retire an offering, change a packaging form, alter availability characteristics, alter platform-imposed constraints. The decision has already been made and is not forced by external pressure. They have flushed out the technical details of the change and (where applicable) prepared a migration guideline for tenants. Where the change replaces an offering with a new one, the replacement offering has already been implemented and is running on the platform alongside the old one.
- What they care about here: Getting every affected tenant migrated onto the new contract by a hard deadline, without surprising anyone, while honoring the evergreen contract promise — change is communicated ahead of time and tenants are migrated, not sprung on.
Goal
“I want to change a term of the platform’s contract — retire an offering, change a packaging form, alter availability characteristics — communicate that change to every affected tenant ahead of time, and have them all migrated onto the new contract before the old one is retired, without surprising anyone.”
Entry Point
The operator arrives at this experience having chosen to change the contract. The deciding (the why — cost, simplification, security posture, no longer wanting to maintain two runtimes, etc.) is upstream of this UX and not part of it. What they have in hand at step 0:
- The full technical details of the change — what term is changing, what it is becoming, or that it is being removed entirely.
- A migration guideline for tenants, where applicable (i.e. when a replacement offering exists and tenants need to repackage or reconfigure against it).
- The replacement offering, if one exists, already implemented and running on the platform. Building the replacement is a precondition of this journey, not a step inside it.
- Knowledge of which currently-hosted tenants are using the affected term.
The operator’s state of mind is “I have decided this is changing; how do I get everyone moved over by a date I’m choosing, without anyone being surprised?”
The seam with operator-initiated-tenant-update (UX #2) is sharp: that journey is reactive (an external event — vendor sunset, CVE, EOL — forced the update and dictated the deadline). This journey is proactive (the operator chose the change and is choosing the deadline).
Journey
1. File a “platform contract change” umbrella issue
The operator opens a single umbrella issue against the infra repo using the platform contract change issue type. This is a distinct issue type, separate from onboard my capability, modify my capability, and platform update required. The distinct type is the signal to capability owners that this is the operator changing the rules — not an externally-forced update and not optional cleanup.
A single umbrella issue is used (rather than one issue per tenant, as in UX #2) because the change applies identically to everyone, the migration guideline is shared, and tenants benefit from cross-tenant visibility — a clarifying question one tenant asks may be the answer another tenant needed.
The umbrella issue tags every affected capability owner and contains:
- What term is changing, and what it is changing to (or that it is being removed entirely).
- The migration guideline, if applicable.
- The hard deadline by which all migrations must complete and after which the old form will be removed. Because this UX has no externally-imposed date to inherit, the operator picks a deadline that gives every affected tenant at least two full status-update cycles before cutoff: one cycle to acknowledge and start, and one cycle to finish or surface blockers while there is still time to respond.
- The reason for the change. Even though the operator chose it, capability owners deserve to know why (cost, simplification, security posture, etc.) so they can plan and so the trail makes sense to readers later.
- The status-update cadence the operator has chosen for this rollout (see step 3).
What the operator perceives at this point: the umbrella issue is filed and every affected capability owner has been notified. They wait for acknowledgments.
2. Capability owners acknowledge in-thread
Each tagged capability owner is required to acknowledge the change in-thread. Silence is not acceptable in an umbrella issue — silence in a multi-tenant thread is ambiguous (did they see it? are they planning?), so explicit acknowledgment is the contract.
Clarifying questions are asked in the umbrella thread, not in side channels, so that answers are visible to every other affected tenant at the same time. The operator answers questions as they come.
The deadline is not negotiable per-tenant. Capability owners do not get to ask for a slip — the deadline applies uniformly to everyone or it isn’t a deadline. (Whether the operator may globally push the deadline if the migration guideline turns out to be insufficient is covered in Edge Cases.)
3. Tenants migrate via separate modify my capability issues
Each affected tenant ships its migration as a separate modify my capability issue, linking back to the umbrella issue for context. The umbrella thread tracks acknowledgments, cross-tenant questions, and the global deadline; each modify issue tracks the actual artifact handoff / provision / test / close inner loop for one tenant. This keeps the umbrella thread readable as a coordination surface rather than a sprawling multi-tenant inner loop.
During the rollout window, the platform serves both the old and the new form of the contract concurrently — the replacement offering runs alongside the old one so tenants have time to migrate at their own pace within the deadline. The exception is a full offering removal: when there is no replacement, there is nothing to run alongside, and the change is effectively all-or-nothing at the deadline.
The operator posts status updates on a regular schedule in the umbrella thread. The cadence is chosen by the operator at the time the umbrella issue is filed and is sized to the overall timeline — daily for a roughly-week-long rollout, weekly for a roughly-month-long rollout, and so on. The current snapshot lives in the umbrella issue body so a reader landing cold can immediately see the latest state, and each scheduled update is also posted as a thread comment so the history of the rollout remains visible to watchers over time. Each update carries the same metrics: how many tenants are still on the old form, how many have migrated, which modify issues are open, and how much time remains until the deadline. Status updates are how every party — operator and capability owners alike — sees rollout progress without having to chase it.
4. Deadline arrives
On the hard deadline:
- For each tenant whose migration completed: the
modifyissue is closed in the normal way and that tenant is now on the new form. - The old form is removed from the platform regardless of whether anyone is still on it. Any tenant that has not migrated by the deadline is now broken on a removed offering — which is exactly why the operator must ensure laggards are moved into eviction before this point if it is clear they will not make it.
- For each tenant that did not migrate by the deadline: the operator opens a separate eviction issue per laggard tenant, linking back to the umbrella issue for context. The eviction issue carries its own eviction date and is governed by the parent capability’s eviction journey (to be defined as its own UX).
- The umbrella issue is closed. Its job ends here — every affected tenant has either completed migration (their
modifyissue closed) or has an eviction issue in-flight (linked from the umbrella). Subsequent activity for laggards lives on their respective eviction issues, not on the umbrella.
Flow Diagram
flowchart TD
Start([Operator has decided to change<br/>a contract term; replacement<br/>offering already implemented]) --> File[Operator files 'platform contract change'<br/>umbrella issue, tags all affected tenants]
File --> Ack[Each capability owner acknowledges<br/>in-thread; questions answered in-thread]
Ack --> Modify[Each tenant ships a separate<br/>'modify my capability' issue,<br/>linked to the umbrella]
Modify --> Concurrent[Old + new forms run concurrently<br/>during the rollout window<br/>except for full removals]
Concurrent --> Status[Operator posts scheduled status<br/>updates with migration metrics<br/>in the umbrella thread]
Status --> Deadline{Deadline<br/>arrives}
Deadline --> Migrated[Migrated tenants:<br/>'modify' issue closes]
Deadline --> Laggards[Non-migrated tenants:<br/>operator opens a separate<br/>eviction issue per laggard,<br/>linked to umbrella]
Deadline --> Remove[Old form is removed<br/>from the platform]
Migrated --> CloseUmbrella[Umbrella issue closed]
Laggards --> CloseUmbrella
Remove --> CloseUmbrella
CloseUmbrella --> Done([Contract change has shipped])Success
When the umbrella issue closes, the operator walks away with:
- The contract change has shipped — the old form is gone from the platform, the new form is the only form.
- Every affected tenant has either migrated onto the new contract or has an eviction issue in-flight; no tenant is silently broken on a removed offering.
- The evergreen contract promise was honored: the change was announced ahead of time with a migration guideline and a hard deadline, no tenant was surprised at retirement, and tenants were given a coordinated window in which both old and new ran concurrently (except for full removals, where concurrency is impossible).
- A trail across the umbrella issue, the per-tenant
modifyissues, and any linked eviction issues — showing what changed, why, who migrated when, and who didn’t. Useful the next time a contract change ships.
Edge Cases & Failure Modes
- Capability owner does not acknowledge in the umbrella. Experience-level handling: the operator chases — in-thread mention, direct ping, separate message as the deadline approaches. If no acknowledgment arrives by the deadline, the missing acknowledgment is treated as non-engagement and the laggard branch (eviction issue per tenant) applies. Acknowledgment is required, but the consequence of withholding it is the same as failing to migrate.
- Migration guideline turns out to be wrong or insufficient mid-rollout. Two sub-cases:
- Isolated miss (the guideline doesn’t cover one tenant’s specific case): the fix is tenant-specific, every other tenant can keep migrating without changing their plan, the guideline is amended in the umbrella thread, and the deadline does not move.
- Big miss (the shared guidance or replacement itself must change for the remaining tenants): the deadline is pushed out and the new deadline is announced in the umbrella thread. The hard-deadline rule still applies to the new date — extension is a global event, not a per-tenant slip.
- Tenant says outright “we can’t migrate — the new contract makes our capability unviable.” Straight to eviction. The capability owner now has to find a new platform or revamp their capability so that it works with the new contract. The umbrella issue still tracks this tenant via the linked eviction issue at deadline time, but the migration itself is not going to happen.
- Full offering removal (no replacement to run alongside). Step 3’s “old + new run concurrently” does not apply. The change is all-or-nothing at the deadline. Tenants must be off the offering by the deadline; there is no grace window during which both forms exist. Migration in this case usually means moving to a different offering entirely or moving the workload off-platform — whichever the migration guideline directs.
- Many tenants miss the deadline at once. This is a signal that the operator picked a deadline that was too aggressive given the size of the change, or sized the status-update cadence poorly for the work involved. The hard-deadline rule still applies — the operator opens an eviction issue per laggard — but the operator should treat the cluster of evictions as a learning event for the next contract-change rollout.
- Cross-tenant question reveals a conflict in the migration guideline. Same shape as the isolated-miss branch above: amend in-thread, continue. The umbrella thread is the source of truth for the guideline as it evolves during the rollout.
- Two contract changes are in flight at once and the same tenant is affected by both. Each change still gets its own umbrella issue and the tenant is expected to acknowledge in each thread. If one migration satisfies both changes, the tenant may use one combined
modify my capabilityissue, provided it links back to both umbrellas so each rollout can still be tracked and closed independently.
Constraints Inherited from the Capability
This UX must respect the following items from the parent capability — by name, so the lineage is traceable:
- Evergreen contract. This UX is the operationalization of the evergreen-contract promise made in
host-a-capability. Contract changes are communicated ahead of time, tenants are migrated, and no tenant is sprung on. The hard deadline plus the rollout-window concurrency (where applicable) is what “communicated ahead of time and migrated” actually looks like in practice. - Operator-only operation. Only the operator can change the contract, and only the operator can see across tenants to know which ones are affected. The umbrella-issue mechanic is consistent with this — it is the operator’s tool, not a capability-owner-driven coordination surface.
- Tenants must accept the platform’s contract. After this rollout completes, the new contract is the contract every remaining tenant has accepted. Acceptance is implicit in their having migrated; tenants that cannot accept the new contract end up evicted, which is consistent with the parent rule.
- Eviction is allowed when needs and capabilities diverge. Laggards who miss the deadline are evicted via the parent capability’s eviction journey. This UX feeds eviction; it does not perform it.
- Eviction threshold. A missed deadline is the operational signal that continuing to accommodate this tenant would either push routine maintenance sustainably above the 2×-budget threshold or break reproducibility (e.g. by forcing the platform to keep the old form running indefinitely just for one tenant). The numeric threshold lives with the KPI; this UX inherits whatever it currently is.
- The capability evolves with its tenants. There is real tension between this rule and the present UX: this rule says the default response when a tenant needs something is to update the platform rather than push the requirement back. Yet this UX is the operator pushing change toward tenants. The reconciliation: this UX applies when the operator has already decided to change the contract — typically because the cost of continuing to support the old form (maintenance, security posture, complexity) has tipped against keeping it. The migration-guideline + concurrent-rollout shape is how the platform absorbs as much of the cost as it can. But once the deadline is set, it is set.
- No specific availability or performance SLA. Contract changes that affect availability characteristics are in scope of this UX (the operator may alter availability characteristics under the rules of this rollout). Tenants needing stronger guarantees than the new contract offers are subject to the same eviction path as any other “fundamentally incompatible” case.
- KPI: 2-hr/week operator maintenance budget. Implication for this UX: the rollout cadence — including status updates and per-tenant
modifyreviews — must fit within the operator’s weekly budget across the rollout window. A contract change that would clearly blow the budget is a signal to reduce the scope of the change, lengthen the deadline, or stage the rollout, before the umbrella issue is filed. - KPI: 1-hour reproducibility. Implication for this UX: the new contract must itself be reproducible from definitions. A contract change that ships with the platform itself stuck on a snowflake configuration to keep both forms running has failed the rule. Concurrent old/new during rollout is fine; permanent dual-form support is not the goal.
Out of Scope
- Externally-forced updates. Vendor sunset, CVE remediation, runtime EOL — those are reactive updates with deadlines inherited from outside, and they belong in
operator-initiated-tenant-update(UX #2). The seam: that UX is reactive; this UX is proactive. - Routine modify requests. A capability owner shipping a version bump or new component on their own initiative is the change-later loop in
host-a-capability, not this UX. - The eviction journey itself. When a laggard misses the deadline, the operator opens a separate eviction issue per tenant. The capability owner’s experience continues in Capability owner moves off the platform after eviction — a sibling UX, not part of this one.
- The decision to change the contract. Why the operator chose to retire an offering, change a packaging form, or alter availability characteristics is upstream of this UX. The journey starts the moment the operator has decided.
- Building the replacement offering. Where the contract change replaces an old offering with a new one, the replacement must already be implemented and running on the platform before the umbrella issue is filed. Building it is a precondition, not a step in this UX. (Following on from
host-a-capability’s “new offering needed” branch — which was the path by which the new offering may originally have entered the platform.) - The capability owner’s responder side as a separate doc. As with UX #2, the capability owner’s experience of receiving and responding to the umbrella issue is captured here as a responder. The actual migration work runs through the existing
modify my capabilitysurface, which is already documented inhost-a-capability. A separate doc would mostly duplicate.
Open Questions
None at this time.
1.1.6 - Stand Up the Platform
One-line definition: The operator rebuilds the platform from its definitions — back to ready-to-host-tenants — confidently and verifiably, whether it’s the first build ever, recovery after total loss, or a periodic drill.
Parent capability: Self-Hosted Application Platform
Persona
The actor here is the operator — the parent capability’s Owner / Accountable party and sole administrator. There are no co-operators in this journey, and the sealed successor credentials are not in play during routine standup.
If a successor has taken over (because the primary operator is unavailable), they run this same journey. The act of breaking the seal and asserting takeover is a separate experience; once they have access to the operator’s context, the rebuild flow is identical. From this UX’s perspective there is one persona — whoever is currently the operator.
- Role: The operator. Sole party with administrative access to the platform and accountable for it existing and running.
- Context they come from: Either there is no platform yet (first-ever build) or the platform is gone / being rebuilt in parallel. Either way, what they have in hand is the definitions repo, root-level access to the underlying infrastructure (cloud account, home-lab), and — for disaster recovery — backups of tenant data sitting somewhere reachable.
- What they care about here: Confidence that the platform really is reproducible from its definitions. Speed matters too (the Reproducibility KPI is 1 hour) but takes second place — a fast rebuild that leaves the operator unsure whether anything was missed is worse than a slower one that finishes verifiably clean.
Goal
“I want to rebuild the platform from nothing back to ready-to-host-tenants — confidently and verifiably, fast enough that the 1-hour KPI holds — so that total loss is recoverable, not catastrophic.”
Confidence beats speed when the two conflict. The operator is rebuilding the substrate that everything else of theirs depends on; a hurried rebuild that they don’t trust is its own kind of failure.
Entry Point
Three triggers converge on this same flow:
- First-ever build. No platform has existed before; the operator is bringing it into being.
- Disaster recovery. The platform existed and is now gone (cloud project lost, home-lab destroyed, ransomware, etc.); the operator is rebuilding on top of root-level access that survived the disaster.
- Drift / reproducibility drill. The operator rebuilds the platform in parallel on scratch infrastructure after every significant platform change — meaning any change that would alter what they are rebuilding, what they must validate, or what they must trust before calling the platform ready again — and at least quarterly to prove the KPI still holds while the live platform keeps serving. The drill is identical to the real flow — only the underlying infrastructure differs.
What the operator has in hand at minute zero:
- The definitions repo, pulled fresh.
- Root-level access to the underlying infrastructure (cloud-provider account, home-lab access). Loss of these is not in scope for this UX — they are foundational and must already be in place before the platform can be (re)built.
- For disaster recovery only: tenant-data backups. Restoring those into newly-provisioned tenants is a separate UX; this UX ends before that begins.
The operator’s state of mind is steady, not panicked: this journey exists precisely so total loss isn’t catastrophic, and a drill rehearses it on purpose.
What is not assumed at entry:
- Definitions drift. Before any rebuild with prior platform state starts, the operator performs a required preflight drift check against the live platform or the last known-good environment. On a first-ever build, the check is vacuously clean because there is no prior platform state yet. The check passes only when the platform state the operator is treating as real still matches the definitions closely enough that no unexplained differences remain. If drift exists, it must be detected and fixed before this journey begins, not discovered partway through. (See Constraints.)
- The sealed successor credentials. They stay sealed during routine standup, including DR.
Journey
The rebuild is automated, with manual operator-validation checkpoints between phases. The operator is on standby throughout — watching log output and system-level signals, ready to validate at each checkpoint, but not driving each step by hand.
1. Decide to rebuild and confirm preconditions
The operator decides to rebuild — first build, DR, or scheduled drill — and confirms what they have in hand: a fresh pull of the definitions repo and root-level access to the target infrastructure (the live infra for first-build/DR, scratch infra for a drill). Before they kick anything off, they perform the required preflight drift check whenever prior platform state exists, using the live platform or the last known-good environment as the reference, and confirm the platform they intend to trust still matches the intended definitions closely enough to rebuild from them honestly. If the check fails because unexplained differences remain, they stop and resolve drift before starting the rebuild.
What they perceive: nothing yet on the target infrastructure; a clean definitions repo on their workstation; the underlying provider UIs (cloud console, IPMI) showing the empty starting state.
2. Kick off the top-level rebuild
The operator runs the single top-level entry point that drives the rebuild from the definitions repo. From here on, automation does the work of provisioning; the operator’s job is to validate at each checkpoint.
What they perceive: log output begins streaming. The first phase is underway.
3. Phase 1 — Foundations
Automation provisions the underlying foundations: cloud project / home-lab base, network plumbing including the connectivity between cloud and home-lab. On completion the automation pauses and prints a phase summary.
The operator validates by checking the underlying provider’s UIs (cloud console, home-lab IPMI) and the expected signals for this phase. Only when they are satisfied that the foundations really are in place do they signal continue.
If validation fails, see Edge Cases — Phase fails.
4. Phase 2 — Core platform services
Automation provisions compute, persistent storage, and the platform-provided identity service on top of the foundations. Pauses. The operator validates the same way — provider UIs plus the expected signs that compute, storage, and identity are really available (e.g. the identity service is reachable and issuing tokens) — then signals continue.
5. Phase 3 — Cross-cutting services
Automation provisions backup and observability so they cover the platform itself before any tenant arrives. Pauses. The operator validates that backup is wired in and observability is collecting, then signals continue.
6. Phase 4 — Readiness verification and canary tenant
The platform deploys a purpose-built canary tenant maintained alongside the platform definitions end-to-end. It exists solely to prove the platform can host tenants without coupling readiness to any real tenant’s lifecycle. The trade-off is that this is less representative than using a small real tenant, so it may miss tenant-specific workload quirks; it is preferred anyway because it keeps readiness verification deterministic, disposable, and independent of any real tenant’s lifecycle. The canary is exercised (it should run, be reachable, store and read back data, authenticate against the platform-provided identity service, be picked up by backup and observability), then torn down.
What the operator perceives: a clear pass/fail on the canary. The canary’s success is the readiness signal — “ready to host tenants” is operationally identical to “did host a tenant just now.”
If the canary fails, see Edge Cases — Canary fails.
7. Note the wall-clock and close out
The operator records how long the rebuild took. If it came in under the 1-hour KPI, they’re done — the platform is ready for tenant restoration (a separate UX). If it took longer, the platform is still ready; the operator opens a GitHub issue capturing the cause of the slowdown so it can be analyzed and improved later. Either way, the journey ends here.
Flow Diagram
flowchart TD
Start([Trigger: first build / DR / drill]) --> Confirm[Run required preflight drift check<br/>when prior state exists and confirm<br/>root-level access is in hand]
Confirm --> Kickoff[Run top-level rebuild from definitions]
Kickoff --> P1[Phase 1: Foundations<br/>cloud + home-lab base, networking]
P1 --> V1{Validate via provider UIs<br/>+ maintained checklist}
V1 -->|Fails| Halt[Halt, root-cause,<br/>tear down everything,<br/>fix definition, restart]
V1 -->|OK| P2[Phase 2: Core services<br/>compute, storage, identity]
P2 --> V2{Validate}
V2 -->|Fails| Halt
V2 -->|OK| P3[Phase 3: Cross-cutting<br/>backup, observability]
P3 --> V3{Validate}
V3 -->|Fails| Halt
V3 -->|OK| Canary[Phase 4: Deploy purpose-built<br/>canary tenant, then tear down]
Canary --> CanaryGreen{Canary green?}
CanaryGreen -->|No| Halt
CanaryGreen -->|Yes| Wallclock[Note wall-clock duration]
Wallclock --> KPI{Under 1 hour?}
KPI -->|Yes| Ready((Platform ready<br/>to host tenants))
KPI -->|No| Issue[Open GitHub issue<br/>to analyze the slowdown]
Issue --> Ready
Halt --> KickoffSuccess
When the canary comes up green and is cleanly torn down, the operator walks away with:
- A platform that is ready to host tenants — every platform-provided service has been exercised end-to-end by a purpose-built tenant deployment, not just by self-checks.
- Confidence in reproducibility. The rebuild ran from the definitions repo, with no manual snowflake configuration, and produced a working platform. The KPI is honestly met (or, if not, the gap is captured for follow-up rather than papered over).
- A clean handoff to tenant restoration. Any previously-hosted tenants come back via their own restoration journey; the platform-side standup ends cleanly without entangling itself in tenant data.
- For drills specifically: a renewed assurance that “we can rebuild this in an hour” is a real property, not a hope, because the drill is run after every significant platform change and at least quarterly rather than whenever it feels convenient.
Edge Cases & Failure Modes
Phase fails mid-rebuild. The automation hits an error during one of the phases. The operator halts, root-causes the failure, fixes the underlying issue (typically a definition that needs updating), tears down everything that was provisioned so far, and restarts the rebuild from the top. Partial state is itself a snowflake risk and is not trusted. This implies each phase must be reversible — at minimum, “delete everything” must be a viable rollback. (See Constraints.)
Preflight drift check fails. The rebuild does not start. The operator treats this as a definitions integrity problem, reconciles the drift, and only re-enters this journey once the required preflight check passes.
Definitions are drifted despite the preflight check. Drift is supposed to be prevented by the platform’s enforcement of tracked changes and immutability, and detected/fixed before this journey starts. If drift still surfaces during the rebuild (e.g. the canary fails because something expected by the definitions is missing or inconsistent), the operator treats it as a definitions bug — fix the definition, tear down, restart.
1-hour KPI is missed. The platform is still up and ready for tenants. The operator records the wall-clock and opens a GitHub issue to analyze why it took longer than it should have. The KPI is missed for that rebuild, but the platform doesn’t get blocked from going back into service; KPI improvement is a follow-up concern, not part of this journey.
Canary tenant fails to come up. The platform is not ready, regardless of how green every prior phase looked. The operator root-causes the canary failure, fixes the relevant definition, tears down, and restarts. Until the canary is green, the platform is not marked ready for tenants — even if the operator is under time pressure, this rule does not bend.
Successor at the keyboard. A successor who has taken over runs this same journey from the operator’s context. The act of breaking the sealed credentials and asserting takeover is a separate UX (not yet defined); once the successor is in, the rebuild flow does not differ.
First build has no backups. First-build and DR/drill produce the same platform-side outcome from this UX’s perspective. Tenant data restore is out of scope here, so the absence of backups during a first build is simply a non-event for this journey.
Constraints Inherited from the Capability
This UX must respect the following items from the parent capability — by name, so future readers can trace the lineage:
KPI: 1-hour reproducibility. This is the journey the KPI is measured against. The 1-hour budget is a target, not a hard fail — missing it does not stop the platform from going into service, but it does generate a tracked follow-up issue. The KPI cannot be honestly evaluated unless drills run this same flow on parallel infrastructure after every significant platform change and at least quarterly.
Operator-only operation. No co-operators, no delegated administration, no shared driving of the rebuild. The sealed successor credentials are not used during routine standup, including DR. A successor uses them only after takeover, and from that point operates as “the operator” through this same UX.
The platform may span public and private infrastructure. Phase 1 (foundations) explicitly crosses cloud and home-lab boundaries — the rebuild is not a single-environment affair. Connectivity between the two is part of the foundation, not an afterthought.
Reproducibility beats vendor independence beats minimizing operator effort (the parent capability’s stated tiebreaker). Manifests here as the rule that partial state is not trusted: tearing down and restarting from scratch on any phase failure is more operator effort than incremental fix-and-resume, but it is what reproducibility honesty requires.
Operator succession. Successor takeover converges on this same UX — sealed credentials grant access to the operator’s context, after which the rebuild flow is identical. The seal-breaking event itself is a separate journey.
No specific availability or performance SLA. The journey ends at “ready to host tenants” — what tenants experience after that is governed by the platform’s normal availability characteristics, not by this UX.
Tracked changes and immutability across all platform UXs. The required preflight drift check is only meaningful if every UX that can introduce platform state enforces tracked changes and immutability rather than allowing ad-hoc modification. This is a property the platform’s definitions and operations must hold, not a step that invents drift policy on its own — but this UX is the one that refuses to proceed until that policy is verified.
Each phase must be reversible. Implied by the “phase fails → tear down everything and restart” edge-case rule. The platform’s definitions must support a clean teardown of any partially-provisioned state. “Delete everything and start over” must be a viable, reliable option at every checkpoint.
Default hosting target for the operator’s capabilities. Readiness cannot be declared from infrastructure self-checks alone; the platform has to prove it can actually host a tenant. That is why this UX requires a purpose-built canary tenant maintained with the platform definitions.
Out of Scope
Tenant data restoration. Bringing previously-hosted tenants’ data back into newly-provisioned tenants is a separate UX. This journey ends at “platform is ready to host tenants” — full stop.
Re-onboarding tenants after rebuild. Each tenant’s return is governed by its own journey (likely a variant of Host a Capability, possibly seeded by a backup-restore step). Not handled here.
Migration to new underlying infrastructure. Moving the platform to a different cloud account or different home-lab hardware while the old one is still running is a different journey (the old platform serves traffic while the new one comes up). Out of scope until migration becomes a realistic case worth defining.
Sealed-credential takeover by the successor. The act of breaking the seal, asserting authority, and gaining access to the operator’s context belongs in its own UX. This UX picks up after takeover, where the successor is operating as the operator.
The broader drift-management process. This UX requires a preflight drift check before rebuild, but the wider machinery that continuously enforces tracked changes, detects drift between rebuilds, and maintains the last known-good reference is a cross-cutting concern, not the focus of this journey.
Loss of root-level foundations (cloud account itself, all home-lab access). These are assumed in place before the platform was deployed in the first place. Recovery from their loss is not part of the platform’s capability.
Open Questions
None at this time.
1.1.7 - Tenant-Facing Observability
One-line definition: A capability owner with a live tenant checks whether their hosted capability is healthy — either pulling the view themselves or being pushed an alert when something crosses a threshold they set.
Parent capability: Self-Hosted Application Platform
Persona
The actor is a capability owner whose capability has already been onboarded via Host a Capability and is currently running on the platform. As with that UX, this is written as if the capability owner were a separate person from the operator, even though today they are the same human wearing different hats.
- Role: Capability owner of a live tenant. They are not operating the platform; they are operating their capability, which happens to be hosted here.
- Context they come from: Their capability is live and serving its end users. They are not in the middle of onboarding or modifying — that’s a different journey. They want to know how their thing is doing right now, or they have just been pinged that something is wrong.
- What they care about here: Knowing the health of their capability without depending on end users to report problems first, and without having to interrupt the operator to ask.
Goal
“I want to know whether my hosted capability is healthy right now — and I want the platform to ping me if it isn’t — so I find out before my end users do, and I can tell whether the problem is mine to fix or the platform’s.”
Two arrival modes share this goal: proactive pull (capability owner goes looking) and reactive push (an alert reaches them). The view they reach is the same in both cases.
Entry Point
Two distinct entries, converging on the same view.
Pull entry. The capability owner opens the observability offering’s tenant view. They might be doing this:
- Routinely (e.g. before promoting a new release of their capability to end users).
- Reactively, because an end user reported something looked off and they want to confirm.
- Out of curiosity / habit.
They reach it by authenticating to the shared observability offering. After login they land directly in their own tenant’s view and stay confined there for the rest of the session. There is no separate URL per tenant — the same offering serves everyone, but capability owners do not browse across tenants or switch into an operator-wide view.
Push entry. The platform’s alerting reaches them by email. They are pulled away from whatever they were doing and now have a concrete “your capability looks unhealthy” signal in hand.
In both cases their access was provisioned automatically as part of the original onboard my capability flow (step 5 of Host a Capability) — observability is part of being hosted, not an add-on they request later.
Journey
1. Access is already in place (set up during onboarding)
By the time the capability owner has a live tenant, they already have:
- A working login to the observability offering, scoped to their tenant.
- Email alerting wired to the address they use for platform communication.
- A platform-standard health bundle for their capability: availability, latency, error rate, resource saturation, and restart / deployment events.
- A clear contract about trust: the tenant view is the source of truth for current health, while email alerts are a best-effort nudge that helps them notice trouble sooner.
Nothing in this UX requires them to set any of that up. Threshold tuning happens inside the observability offering, but any request to expand the signal bundle or add new delivery channels goes through modify my capability — not this journey.
2. (Pull mode) Capability owner opens the observability view
They authenticate to the observability offering and land on a view scoped to their tenant. They see the current state of the platform-standard health bundle for their capability: whether it is up, how quickly it is responding, how often it is failing, whether it is under resource pressure, and whether it has recently restarted or been redeployed.
What they perceive: a current-state read of their capability’s health, plus enough recent history to tell whether something is trending bad. They cannot see other tenants, and there is no mode-switch that broadens their scope — only the operator can do that.
3. (Pull mode) Capability owner tunes thresholds, if needed
While in the offering, the capability owner can self-serve their alert thresholds — the values that, when crossed, will fire an email alert to them. Thresholds are their call: the platform does not prescribe what’s unhealthy enough to wake them up.
This is the one self-service surface the platform exposes to capability owners. Everything else still goes through GitHub issues; thresholds are an exception because they are a tuning knob the capability owner needs to iterate on without operator involvement.
If the observability offering knows email delivery is degraded for this tenant, it says so in the tenant view. What the capability owner perceives: do not treat silence from email as reassurance until the delivery path is healthy again; use the pull view as the authoritative answer in the meantime.
4. (Push mode) An alert reaches the capability owner
A signal crossed a threshold they set. The platform sends an email alert. The alert names which signal and which capability — enough for them to start without opening anything else.
What they perceive: their capability is unhealthy enough that they wanted to be told. They now have to figure out whose problem it is.
5. Root-cause — is this the tenant or the platform?
The capability owner investigates. They have two possible conclusions:
5a. It’s the tenant. The signals point at their capability — their code, their data, their config. They handle it the way they would handle any problem with their capability: fix on their side, ship a new artifact via modify my capability if it requires a deployment, or operate within the running tenant if the tools to do so exist.
5b. It’s the platform. The signals point at something below their capability — the host is gone, networking is broken, the storage offering is degraded. They look for an open operator-side issue tracking it. If the operator has already opened one, they watch that issue; the operator owns the fix, and the capability owner’s role from here is to stay aware so they can communicate to their own end users. If no such issue exists yet, the operator will probably open one shortly (the operator gets the same signals); the capability owner does not need to file anything themselves.
6. Resolution
Either:
- They fix their side of it and signals return to healthy. The alert (if there was one) does not need to be acknowledged — the platform stops alerting because the threshold is no longer crossed.
- The operator fixes the platform side of it and signals return to healthy. The operator-side issue closes. The capability owner has been a passive watcher.
In either case the capability owner walks away with the same end state: their capability is healthy again, and they knew about the unhealth without an end user telling them.
Flow Diagram
flowchart TD
Onboarded([Capability is live — observability access<br/>and email alerts provisioned during onboarding]) --> Trigger{What brought<br/>them here?}
Trigger -->|Routine check / end user reported| Pull[Open observability view —<br/>see tenant-scoped signals]
Trigger -->|Push alert from platform| Alert[Receive email alert:<br/>which signal, which capability]
Pull --> Tune{Want to adjust<br/>thresholds?}
Tune -->|Yes| Self[Self-serve threshold change<br/>in the observability offering]
Tune -->|No| Read[Read the signals]
Self --> Read
Read --> Healthy{Healthy?}
Healthy -->|Yes| Done([Walk away — capability is fine])
Healthy -->|No| RootCause
Alert --> RootCause[Root-cause: tenant or platform?]
RootCause -->|Tenant| FixSelf[Fix on their side —<br/>via 'modify my capability' if needed]
RootCause -->|Platform| Watch[Watch the operator's issue —<br/>operator opens one, capability owner observes]
FixSelf --> Recover([Signals recover])
Watch --> RecoverSuccess
A successful experience looks like:
- The capability owner learned about a health problem before their end users had to tell them, or confirmed health proactively before promoting a change.
- They could tell, from the signals alone, whether the problem was theirs or the platform’s — without having to interrupt the operator to ask.
- If it was theirs, they fixed it through the channels they already use (modify issue, in-tenant tools, redeploy).
- If it was the platform’s, they had something concrete to watch (the operator’s issue) and could relay status to their own end users.
- They understood that the tenant view was authoritative and email was an acceleration path, so silence from email was never the only evidence they relied on.
- They did not have to set anything up to make this work — onboarding put it in place.
Edge Cases & Failure Modes
- Alert fatigue / ignored alerts. A capability owner who stops responding to their own alerts is not the platform’s problem — alerts are a courtesy; tenant health is tenant responsibility. The platform keeps emitting; what the capability owner does with them is their call.
- Threshold set too tight, capability owner spammed. Self-serve thresholds means the capability owner can fix this themselves. The platform does not intervene to “save them from themselves.”
- Threshold set too loose, real problems missed. Same — their call, their consequence. The platform’s defaults (whatever the observability offering ships with) provide a starting point.
- Operator hasn’t opened a platform-side issue yet when the capability owner is investigating. The capability owner does not need to file one themselves. The operator gets the same signals and will open one. If they don’t and the problem persists, that is an operator-side failure, not a capability-owner-side action.
- Capability owner suspects the platform but signals look fine for the platform. They surface this on a modify my capability issue or a comment to the operator — same surface they would use for anything ambiguous. This UX does not introduce a new issue type for “I think it’s you, not me.”
- Alert delivery is broken (email bounces, mailbox rule hides it, etc.). The capability owner does not treat silence from email as proof of health; the pull view remains the source of truth. If the offering knows delivery is failing, the tenant view shows alerting as degraded so the capability owner understands email is currently unavailable as a nudge.
- Capability owner wants more than email alerts or wants a broader signal bundle. Goes through
modify my capability, not this UX — it’s a contract change about what the platform delivers to the tenant, even if a small one.
Constraints Inherited from the Capability
This UX must respect the following items from the parent capability’s Business Rules and Success Criteria:
- Operator-only operation. The capability owner is not an operator. Their access is scoped to their own tenant; the operator is the only role that sees across tenants. The one self-service surface (threshold tuning) does not violate this — it adjusts only their own email alerts, not platform configuration.
- Direct outputs include observability. The parent capability lists observability as a direct output: “the operator can tell whether each tenant is up and healthy without the tenant having to instrument that itself.” This UX extends that same plumbing to the capability owner with tenant-scoped data access — observability is offered as an offering, with cross-tenant visibility kept to the operator.
- No direct end-user access to the platform. The capability owner’s end users do not get observability access. This view stops at the capability owner.
- Tenants must accept the platform’s contract. The signals available are the platform-standard health bundle — availability, latency, error rate, resource saturation, and restart / deployment events. Capability owners do not ask the platform to instrument arbitrary tenant-specific metrics as part of this UX.
- The capability evolves with its tenants. If multiple tenants need observability beyond the standard health bundle, the right response is to expand the offering’s category — not to push instrumentation back onto the tenant.
- KPI: 2-hr/week operator maintenance budget. Implication: the alerting path must not produce so many false positives that the operator is constantly fielding “is this me or you?” questions from capability owners. Self-serve thresholds and “operator gets the same signals” are both pressure-reliefs on this — capability owners can tune their noise themselves, and they do not need to escalate “is this the platform?” questions to the operator because they can read the signals directly.
Out of Scope
- Changing the signal bundle or adding non-email alert channels. Both go through Host a Capability’s modify loop, not here. They are contract changes about what the platform delivers.
- End-user-facing observability. End users of a tenant do not get a “is the thing I use up?” view from the platform. If a tenant wants a status page for their end users, that is a feature of the tenant capability, not the platform.
- Operator-side observability. The operator’s view across all tenants is its own surface, used during operator-driven journeys (rebuild, contract rollout, eviction decisions). This UX is strictly the capability owner’s slice.
- Platform-side incident management. When the capability owner concludes “this is the platform’s problem and I’ll watch the operator’s issue,” what the operator does inside that issue is operator workflow — not part of this UX.
- Threshold-tuning best practices. This UX provides the surface for self-serve threshold tuning; it does not document what thresholds a capability owner should pick. That belongs in the observability offering’s own documentation.
Open Questions
None at this time.
1.2 - Architecture Decision Records
Capability-scoped ADRs for the Self-Hosted Application Platform. Numbering is local to this capability (starting at 0001). Each ADR moves proposed → accepted → (later) superseded; plan-tech-design refuses to compose the tech design until every ADR is accepted.
1.2.1 - [0001] Cross-Environment Topology
Parent capability: Self-Hosted Application Platform Addresses requirements: TR-03, TR-17, TR-01, TR-04, TR-18
Context and Problem Statement
TR-03 forces the rebuild’s first phase to establish foundations across both a public-cloud environment and a home-lab environment plus the connectivity between them; single-environment standup is explicitly not a supported outcome. TR-17 requires each tenant to receive compute, persistent storage, internal and external network reachability, identity, backup/DR, and observability — implemented as shared platform offerings. Neither TR names a topology.
The platform therefore needs a decided foundational environment shape: what sits on the public-cloud side, what sits on the home-lab side, and how the two connect. This decision is capability-scoped by confirmed framing — the platform owns its cross-environment topology, and every capability hosted on the platform inherits whatever shape this ADR exposes rather than deciding its own. The topology exposed to end users may differ from the platform-internal topology decided here.
The open question is whether the platform inherits the shape the repository already realizes — an Internet-facing edge (mutual-auth + traffic-control duties) in front of a public-cloud anchor, with a secure tunnel back to a private home lab — or selects a different shape for its cross-environment foundations.
Decision Drivers
- TR-03 — both environments and the link between them are phase-1 foundations, not afterthoughts; a single-environment shape is disqualified outright.
- TR-17 — the shape must provide a clean external reachability tier for tenant applications, cross-environment reachability for platform operation, and must not concentrate backup/DR so narrowly that a single environment’s loss is unrecoverable. (TR-17’s internal reachability — between tenants — is not satisfied by the operations plane; see the amendment note under Decision Outcome.)
- TR-01 / TR-02 (def, rebuild) — the whole topology must be expressible as version-controlled definitions and rebuildable end-to-end within 60 minutes; reusing shapes already realized as reproducible definitions is favored over shapes that must be built from scratch.
- TR-04 (teardown) — each environment and the connectivity between them must be independently teardown-able at a phase checkpoint.
- TR-18 (admissibility) — the edge and tunnel components must allow configuration control, data export, and credential revocation/rotation without vendor cooperation; more vendor surface is more admissibility risk.
- CLAUDE.md house pattern —
Internet → Cloudflare (mTLS + DDoS) → Home Lab ↔ GCP (WireGuard)is the repository’s documented inter-environment topology, already realized incloud/(mtls/cloudflare-gcp,vpc-networkallow-wireguard,network-load-balancerUDP gateway). Departing from it requires explicit justification. - Capability tiebreaker — reproducibility beats vendor independence beats minimizing operator effort.
- Operator-effort asymmetry between environments (not TR-derived — recorded as an honest driver). Managed public-cloud compute and datastore primitives deliver much of the TR-17 inventory without operator-built machinery, where the home-lab equivalents must be built and operated. Their current low-to-zero cost at this platform’s scale sharpens the asymmetry. No TR ranks hosting cost, so this driver may motivate the option set but cannot by itself justify the outcome — it is subordinate to the TR-anchored drivers above and to the tiebreaker.
Considered Options
Option A — Inherit the three-tier shape (edge → public-cloud anchor → secure tunnel → private home lab)
Keep the shape the repository already realizes: an Internet-facing edge tier carrying mutual-auth and traffic-control (DDoS) duties, a public-cloud environment as the anchor, and a secure tunnel back to the private home lab. Tenant workloads may be placed in either environment (see the placement sub-decision in the outcome below).
- Satisfies TR-03: two environments plus their connectivity, all already foundation-phase concerns.
- Strongest on TR-01/TR-02: the definitions already exist and are already reproducible, so it is the shortest path to a ≤60-minute rebuild.
- Provides the dedicated external-reachability + scrubbing tier that TR-17 external reachability wants, while the tunnel carries cross-environment reachability for platform operation.
- Ranks highest on the reproducibility tiebreaker (reuse of proven definitions).
- Cost: carries the most vendor surface (a dedicated edge vendor), the weakest position on TR-18 and the vendor-independence tiebreaker — the edge vendor must pass the TR-18 admissibility test (config control, export, credential rotation without vendor cooperation).
Option B — Two-environment, edge-less (public-cloud ingress fronts directly)
Keep both environments and the tunnel, but drop the dedicated Internet-facing edge tier; the public-cloud ingress/load balancer terminates external traffic and mutual-auth directly.
- Still satisfies TR-03.
- Better on TR-18 and TR-02 (one fewer vendor, fewer moving parts to rebuild).
- Weakens TR-17 external reachability: loses the edge scrubbing/DDoS tier and pushes all external reachability onto the cloud ingress. Diverges from the CLAUDE.md house pattern and would need that divergence justified.
Option C — Home-lab-primary, cloud-as-thin-edge (inverted anchor)
Invert the anchor: all stateful tenant offerings live home-side; the public cloud shrinks to a minimal always-on relay for external reachability and tunnel termination.
- Satisfies TR-03; ranks highest on the vendor-independence tiebreaker (the cloud becomes a replaceable relay).
- Concentrates blast radius and TR-17 backup/DR on the home lab; external reachability degrades during a home-lab outage; greater distance from the current definitions hurts TR-02 reproducibility speed. Loses on the reproducibility tiebreaker.
Option D — Single-environment (all-cloud or all-home-lab)
- Rejected by TR-03, which explicitly makes single-environment standup an unsupported rebuild outcome. Recorded here so the reason the simplest shape is off the table is auditable.
Decision Outcome
Chosen option: Option A — inherit the three-tier shape, because it satisfies TR-03 directly, is the fastest and most faithful path to the TR-01/TR-02 reproducibility target (its definitions already exist and are proven in cloud/), and wins the capability’s stated reproducibility-first tiebreaker. The TR-18 vendor-surface cost is accepted as a bounded, downstream admissibility check on the edge and tunnel vendors rather than a reason to rebuild the shape from scratch.
The two cross-environment paths are strictly separated planes, and this separation is part of the decision:
- Application data plane (end-user traffic). Deployed tenant applications are reached by end users only through the Internet-facing edge, regardless of which environment hosts them:
end user → edge (mTLS + DDoS) → tenant application. This is the external reachability of TR-17. Tenant application traffic never traverses the operations tunnel. - Operations / maintenance plane. The public-cloud ↔ home-lab tunnel (today WireGuard) exists solely for platform operation and maintenance — the operator’s control of the home-lab environment from the public-cloud side. It carries no tenant application traffic.
Amended 2026-07-18. This sub-decision previously described the operations tunnel as “the internal reachability of TR-17.” That was a misreading, corrected here without changing what the ADR decides. The capability defines TR-17’s internal reachability as reachability between tenants; the tunnel provides platform-internal reachability — the operator reaching the home lab — which is a different thing. The two planes decided above are unchanged and remain correct. What the correction exposes is that tenant-to-tenant reachability across environments has no plane assigned to it: not the tunnel (excluded by rule above), and not the edge (the end-user plane). That gap is now carried as an open question below. It is currently unexercised — no UX in this capability describes tenants calling each other — but it constrains placement, and ADR-0002 depends on it.
Workload placement sub-decision: both environments are valid tenant-hosting targets.
The initial reading of this topology treated the home lab as the sole host for tenant workloads, with the public-cloud anchor limited to edge-facing reachability and the operations plane. That reading is widened here: the public-cloud anchor is also a first-class tenant-hosting environment, because the cloud side already offers managed compute and datastore primitives that satisfy the TR-17 inventory (compute, persistent storage, backup/DR, observability) with materially less operator-built machinery than the home-lab equivalents — which serves the tiebreaker’s third term (minimizing operator effort) without spending anything on the first two.
The consistency rule is what keeps this from fragmenting the topology: placement changes where a workload runs, never how it is reached. A cloud-hosted tenant application is not exposed directly via the cloud provider’s public ingress; it sits behind the same Internet-facing edge as a home-lab-hosted one, so mutual-auth and traffic-control duties stay in exactly one tier and the TR-17 external-reachability story is identical in both environments.
Which environment a given tenant lands in is a placement policy, deliberately not decided here — it is its own decision, scoped to its own ADR.
Offering parity sub-decision: environments may expose unequal offering sets; placement is constrained by tenant need.
TR-17 requires each tenant to receive the full inventory, implemented as shared offerings. It does not require every offering to exist in every environment. This ADR reads it accordingly: an environment is a valid target for a given tenant when it carries every offering that tenant actually needs, not when it mirrors the other environment’s catalog.
The alternative — full parity as a precondition for either environment being a valid target — was rejected because it makes every offering added later a double build before it can ship at all, converting the duplicated-surface cost recorded below from a bounded cost into an unbounded one. Deliberately unequal environments keep that cost proportional to demand: an offering is built in the second environment when a tenant needing it is placed there, not in advance.
What this buys is bounded duplication; what it costs is that placement becomes a matching problem. The platform must therefore know, per environment, which offerings exist, and must refuse a placement whose tenant needs an offering the target environment lacks — a rejection is a correct outcome, not a failure. Making that inventory legible is a tech-design obligation, and the placement-policy ADR consumes it as an input.
Migration sub-decision: placement is fixed at onboarding; cross-environment migration is not a supported platform operation.
The consistent-reachability rule above makes moving a tenant between environments possible by construction — its reachability story does not change when its placement does. This ADR declines to make it supported. Moving a tenant means tearing it down and re-onboarding it in the other environment: an operator-run exercise using the ordinary onboarding path, with no platform-guaranteed data-migration path and no declared downtime budget.
The reason is that first-class migration would obligate every stateful offering, in both environments, forever to expose a matching export/import path — a permanent tax on every future offering, paid to serve a case the platform has no demonstrated demand for. Reachability continuity keeps the door open: if migration is later wanted, this decision is reversible without revisiting the topology, because nothing here bakes placement into how a tenant is reached.
Origin-path sub-decision: the edge→origin hop is mutually authenticated in both environments.
“Reachable only through the edge” is stated above as a property; it is enforced here rather than left as a convention. In both environments the edge→origin hop presents client certificates the origin validates against the edge vendor’s origin-pull trust anchor, and the origin refuses any request that does not present one — so bypassing the edge and reaching an origin directly fails at the TLS layer, not at a firewall rule that could be relaxed.
The public-cloud anchor’s realization of this already exists: cloud/mtls/cloudflare-gcp/ issues the origin keypair, fetches the edge vendor’s authenticated-origin-pull CA trust anchor, and stores both in Secret Manager for the origin to consume. The home-lab side must reach the same posture rather than a weaker one; the two differ in realization, not in strength. Deployment surface that terminates edge traffic without this enforcement is non-conforming to this ADR.
Sequencing sub-decision: the operations tunnel is its own phase-1 checkpoint.
Within rebuild phase 1, the edge and public-cloud anchor stand up first, and the operations tunnel is a distinct checkpoint with its own TR-04 teardown — not one atomic foundations unit. Standing the tunnel up separately follows the plane separation this ADR already draws: the tunnel is operations-plane machinery, and folding it into the same unit as data-plane foundations would couple two things the decision deliberately keeps apart.
The operative benefit is TR-02: a tunnel that fails to come up is torn down and retried against an intact cloud anchor, instead of costing a full foundations teardown and restarting the 60-minute budget. The cost is one more checkpoint boundary to define and keep deterministic. Phase 1 is still not complete until every checkpoint in it has passed — separate checkpoints subdivide the phase, they do not weaken TR-03’s both-environments-plus-connectivity bar.
Home-lab definitions sub-decision: a peer top-level definitions surface, tooling undecided.
The home-lab foundations are expressed as version-controlled definitions in the same tracked-changes repository, as a peer top-level surface alongside cloud/, exposing a teardown entry point per phase. That is the TR-01/TR-04 property this ADR fixes: home-lab state is not a second-class, hand-managed environment, and it is not a separate repository.
Which tooling realizes it is deliberately not decided here. Terraform is the repository’s only definitions precedent, but it is a weak fit for the bare-metal and OS-level state the home lab actually carries, and this ADR is at topology altitude — committing to a tool would pre-empt a component design that is better placed to weigh it. The constraint passed downstream is the property, not the mechanism: same repository, peer surface, deterministic per-phase teardown.
flowchart LR
user([End user])
subgraph edge[Internet-facing edge]
cf[mTLS + DDoS / traffic control]
end
subgraph cloud[Public-cloud anchor]
capp[Tenant applications]
ops[Operator / platform ops]
end
subgraph home[Private home lab]
happ[Tenant applications]
end
user -- application data plane --> cf
cf -- application data plane --> capp
cf -- application data plane --> happ
ops -- operations plane only<br/>secure tunnel --- happBoth hosting targets sit behind the same edge; no tenant application is reachable by bypassing it.
Vendor admissibility (TR-18)
The shape above commits to two vendor-bearing layers. Both were tested against TR-18 — configuration control through the tracked-changes surface, portable data export, and credential revocation/rotation without vendor cooperation — and both are admissible, with two residual risks recorded rather than waved off.
Edge vendor (today Cloudflare) — admissible.
- Configuration control. Edge configuration is driven by the
cloudflareprovider from the definitions repository (cloud/mtls/cloudflare-gcp/), so it is already inside the tracked-changes surface TR-01 requires. No dashboard-only step is load-bearing. - Data export. The edge is a proxy and holds no platform or tenant data of record — the export obligation is close to vacuous by construction. What it does hold, DNS zone state, is expressible as definitions and exportable independently of the vendor.
- Credential revocation/rotation. API tokens are revocable by the operator alone. The origin keypair is generated operator-side (
tls_private_key), never by the vendor, so rotation is a local operation and the private key is never vendor-held.
Residual risk 1 — origin certificates are edge-vendor-issued. The origin certificate comes from the edge vendor’s origin CA and is trusted only by that vendor’s edge. Departing the edge vendor therefore means re-issuing origin certificates from another CA and re-pointing DNS. This is migration work, not a lock-in that prevents departure — it fails no limb of TR-18 — but it is the concrete cost of the “largest vendor-surface commitment” recorded below, and it should be sized rather than discovered.
Residual risk 2 — the trust anchor is fetched live from the vendor. The authenticated-origin-pull CA trust anchor is retrieved over HTTP from a vendor-hosted URL at apply time. A rebuild therefore has a runtime dependency on vendor availability, which sits awkwardly against the TR-02 rebuild guarantee. Vendoring the trust anchor into the definitions repository, with a tracked update path, removes the dependency; the tech design owns that change.
Tunnel (today WireGuard) — admissible, and the least vendor-bearing layer.
WireGuard is self-hosted and configuration-file-driven, with keys generated locally: all three TR-18 limbs are satisfied without a vendor in the loop at all. The admissibility question properly attaches to the endpoints carrying it — the public-cloud side (already definitions-driven via cloud/vpc-network/ and cloud/network-load-balancer/) and the home-lab-side network appliance terminating it.
Residual risk 3 — the home-lab tunnel endpoint is configured out-of-band. Today the home-lab side of the tunnel is GUI-configured on the network appliance and documented as prose, which means it is outside the tracked-changes surface and is drift by TR-01’s definition. This does not make the tunnel inadmissible — configuration control is fully available to the operator, which is what TR-18 tests — but it is an open TR-01 gap, and closing it is part of the home-lab definitions surface decided above.
Consequences
- Good, because the foundations phase reuses already-reproducible
cloud/definitions, keeping the TR-02 ≤60-minute rebuild target reachable and honoring the reproducibility tiebreaker. - Good, because separating the application data plane (edge) from the operations plane (tunnel) gives TR-17 a clean external-reachability tier and a distinct platform-operations tier, and prevents tenant traffic from ever depending on the operations tunnel.
- Bad, because that same separation leaves TR-17’s internal (tenant-to-tenant) reachability unassigned across environments — the tunnel is excluded by rule and the edge is the end-user plane. Two interacting tenants are therefore safely placed only in the same environment until this is decided (see Open Questions), and whole-tenant placement in ADR-0002 is forced rather than merely preferred.
- Good, because routing both environments’ tenant traffic through the one edge keeps mutual-auth and traffic-control duties in a single tier — a tenant’s reachability story does not change when its placement changes, and placement stays a migratable property rather than a baked-in commitment.
- Bad, because the dedicated edge vendor is the largest vendor-surface commitment, making it the weakest point against TR-18 and the vendor-independence tiebreaker; it must clear the TR-18 admissibility test.
- Bad, because two valid hosting environments means TR-17 offerings may have to be realized in both — the requirement is that offerings are shared across tenants, not that they exist once, and a per-environment implementation of each offering is real duplicated surface. The offering-parity sub-decision bounds this cost rather than eliminating it: duplication is paid per offering per environment on demand, not up front for the whole inventory. Divergence between two implementations of the same offering remains a live risk the tech design must contain.
- Bad, because unequal environments make placement fallible in a way full parity would not: a tenant needing an offering the target environment lacks cannot be placed there, and the platform must be able to say so rather than discover it during provisioning. This pushes a per-environment offering inventory into the tech design as a hard requirement.
- Bad, because declining migration support means a placement decision made at onboarding is, in practice, durable for the tenant’s lifetime — a wrong call is corrected by teardown and re-onboarding, with whatever data loss or downtime that entails borne as an operator exercise. Accepted because reachability continuity keeps the decision reversible later without reopening the topology.
- Good, because separating the operations tunnel into its own phase-1 checkpoint keeps a tunnel failure from costing a full foundations teardown, protecting the TR-02 rebuild budget at the price of one additional checkpoint boundary.
- Good, because the edge and tunnel layers were tested against TR-18 and cleared it, converting the accepted vendor-surface cost from an open risk into three named, ownable obligations (see Vendor admissibility above).
- Bad, because hosting tenant workloads on managed cloud primitives deepens exposure to a single cloud provider, pressing on TR-18 (data export, credential rotation without vendor cooperation) more than a home-lab-only placement would. This is accepted because the capability tiebreaker ranks reproducibility above vendor independence, but it is a real concession and each managed primitive admitted as a tenant-hosting offering must pass TR-18 on its own.
- Requires: a downstream component design realizing the home-lab definitions surface decided above — same repository, peer to
cloud/, deterministic per-phase teardown. The shape is settled here; the tooling is that design’s to choose, and there is nocloud/analog to copy. - Requires: a per-environment offering inventory the placement path can consult, so a placement whose tenant needs an absent offering is refused rather than half-provisioned.
- Requires: the home-lab edge→origin hop to enforce origin-pull client-certificate validation, matching the posture
cloud/mtls/cloudflare-gcp/already gives the cloud anchor. - Requires: the edge trust anchor to be vendored into the definitions repository with a tracked update path, removing the rebuild-time dependency on vendor availability (residual risk 2).
- Requires: the home-lab tunnel endpoint’s configuration to be brought inside the tracked-changes surface as part of the home-lab definitions surface, closing the TR-01 drift gap (residual risk 3).
Realization
cloud/mtls/cloudflare-gcp/— the Internet-facing edge trust; the application data plane’s entry point and the realization of the mutually-authenticated edge→cloud-anchor origin path decided above. Issues the operator-held origin keypair, obtains the origin certificate, and stores both plus the authenticated-origin-pull CA trust anchor in Secret Manager for the origin to validate against.cloud/https-load-balancer/,cloud/ip/,cloud/dns/— external reachability plumbing behind the edge.cloud/vpc-network/(allow-wireguardfirewall tag) andcloud/network-load-balancer/(UDP gateway) — the operations-plane tunnel endpoints on the public-cloud side.cloud/rest-api/,cloud/https-load-balancer/,cloud/internal-application-load-balancer/(Cloud Run backends and their network endpoint groups) andcloud/firestore/— the public-cloud-side tenant-hosting offerings: managed compute and persistent storage for cloud-placed tenants, fronted by the edge rather than exposed directly.- Home-lab-side platform offerings (tenant compute/persistent storage per TR-17) — not yet realized; a downstream component design owns the peer definitions surface decided above, its tooling, its per-phase teardown, its edge→origin mutual-auth enforcement, and bringing the home-lab tunnel endpoint’s configuration in-repo. It need not mirror the public-cloud offering set (see the offering-parity sub-decision), but must declare what it does offer so placement can be constrained against it.
tech-design.md(composed later byplan-tech-design) will fold this shape into the final-state narrative alongside the other accepted ADRs.
Open Questions
One remains, surfaced by the 2026-07-18 amendment. The five realization questions this ADR originally carried are resolved and folded into the sections above; the sixth — tenant placement policy — was handed to its own ADR and has since been decided there.
- Tenant-to-tenant reachability across environments (TR-17). Open. TR-17 requires internal reachability, which the capability defines as reachability between tenants. This ADR’s plane separation assigns it no path across environments: the operations tunnel carries no tenant application traffic, and the edge is the end-user data plane. Two tenants that must reach each other are therefore only safely co-placed in a single environment. Deciding this means choosing between routing tenant-to-tenant traffic through the edge (consistent with “reachable only through the edge”, but sends internal traffic out to the Internet and back), opening a third cross-environment plane distinct from the operations tunnel (preserves the tunnel’s operations-only rule at the cost of a new plane to secure and reproduce), or accepting the constraint as a placement rule and never splitting interacting tenants (free, but silently narrows placement as the tenant set grows). Not decided here, because it is currently unexercised — no UX in this capability describes tenants calling each other — and choosing a plane on a hypothetical would be guessing. The trigger to decide it is the first tenant that declares a dependency on another tenant.
Resolved
Tenant placement policy (TR-17). → Decided in ADR-0002. This ADR established that both environments are valid hosting targets, that placement is constrained by offering availability, and that the choice is durable; it deliberately left how a tenant’s environment is chosen to a separate decision bearing on the onboarding and modify flows rather than the topology shape. ADR-0002 resolves it: placement is resolved mechanically from the resource needs the tenant already declares, matched against the per-environment offering inventory this ADR required, defaulting to the public-cloud anchor when both environments qualify, with a recorded operator override — and it is an operator-internal detail rather than part of the platform contract.
Edge/tunnel vendor admissibility (TR-18). → Both layers pass; three residual risks recorded as obligations. The edge vendor clears all three limbs (provider-driven config in the definitions repo, no data of record held, operator-generated keypair and independently revocable tokens); the tunnel is self-hosted with no vendor in the loop. Residual: origin certificates are edge-vendor-issued (departure is migration work, not lock-in), the trust anchor is fetched live from the vendor at apply time, and the home-lab tunnel endpoint is configured out-of-band (Vendor admissibility, Consequences).
Home-lab definitions surface (TR-01/TR-04). → A peer top-level definitions surface in the same repository as
cloud/, with a per-phase teardown entry point — tooling deliberately undecided. The property is fixed here so the home lab cannot become a hand-managed second-class environment; the mechanism stays with a component design better placed to weigh Terraform’s poor fit for bare-metal and OS-level state (Decision Outcome, Realization).Ops-plane sequencing within phase 1 (TR-03/TR-04). → Its own checkpoint. The edge and cloud anchor stand up first; the operations tunnel follows as a distinct checkpoint with its own TR-04 teardown. A failed tunnel is retried against an intact anchor instead of costing a full foundations teardown and the TR-02 budget. Phase 1 still completes only when every checkpoint passes (Decision Outcome).
Cross-environment offering parity (TR-17). → No parity requirement; environments may expose deliberately unequal offering sets, and placement is constrained by what a tenant needs. Full parity would make every future offering a double build before it could ship at all. The cost moved onto placement: the platform must know each environment’s offerings and refuse placements it cannot satisfy (Decision Outcome, Consequences).
Migration between environments (TR-17). → Not supported; placement is fixed at onboarding. Moving a tenant means teardown and re-onboarding through the ordinary path — an operator exercise with no platform-guaranteed data-migration path or downtime budget. First-class migration would tax every stateful offering in both environments forever for undemonstrated demand. Reachability continuity keeps the decision reversible later without reopening the topology (Decision Outcome, Consequences).
Edge→cloud-anchor origin path. → Mutually authenticated in both environments, enforced at the TLS layer. The origin validates the edge’s client certificate against the vendor’s origin-pull trust anchor and refuses requests without one, so “reachable only through the edge” holds by enforcement rather than convention. Realized cloud-side today by
cloud/mtls/cloudflare-gcp/; the home-lab side must match it (Decision Outcome, Realization).
1.2.2 - [0002] Tenant Workload Placement Policy
Parent capability: Self-Hosted Application Platform Addresses requirements: TR-17, TR-09, TR-10, TR-11, TR-19, TR-20, TR-21, TR-01, TR-54
Context and Problem Statement
ADR-0001 established that both the public-cloud anchor and the private home lab are valid tenant-hosting environments, that environments may expose deliberately unequal offering sets, and that a placement is fixed at onboarding because cross-environment migration is not a supported platform operation. It deliberately declined to decide how a given tenant’s environment is chosen, and handed that decision two inputs: the per-environment offering inventory the placement path must consult, and the fact that the choice is durable for the tenant’s lifetime.
This ADR answers what ADR-0001 left open:
- Who decides — the operator, the capability owner, or a rule the platform evaluates mechanically?
- On what basis — declared tenant needs, operator judgment, or a default-with-override?
- What is promised — is placement a visible part of the platform contract a tenant can rely on, or an operator-internal detail?
“When” is not open. ADR-0001’s migration sub-decision already fixed it: placement is chosen at onboarding and is durable; changing it means teardown and re-onboarding through the ordinary path. This ADR does not reopen that, and the consequence it inherits is recorded below — the modify flow (TR-21) never carries a placement delta.
The decision is capability-scoped: it bears on the onboarding and modify engagement flows, not on the topology shape, and every constraint it consumes is internal to this capability.
What the platform already knows about a tenant
The option set is bounded by what a tenant actually tells the platform. TR-09 fixes the declaration set at exactly four items — resource needs (compute, storage, network), the packaged artifact, the identity choice, and acceptance of the platform’s availability characteristics — declared in the tenant’s own tech design rather than at a separate acceptance gate.
No declaration is placement-shaped. There is no data-residency field, no latency or proximity requirement, and no hardware-access declaration anywhere in this capability. Where specialized hardware and regulatory constraints do appear — in the capability’s eviction rule and in the host-a-capability UX’s “the platform will never have GPUs” example — they are grounds for declining to host at all, not inputs that steer a tenant toward one environment. Any option that resolves placement from a declared location preference is therefore proposing a fifth declaration, not reading an existing one.
Decision Drivers
- TR-17 — every tenant receives the full inventory as shared offerings. Combined with ADR-0001’s offering-parity sub-decision, a placement is only valid if the target environment carries every offering that tenant needs; the platform must be able to refuse a placement it cannot satisfy rather than discover the gap during provisioning.
- TR-09 — the declaration set is a hard precondition of the provisioning gate. Growing it is a change to the platform contract, not a free extension.
- TR-11 — when a tenant needs an offering the platform lacks, the flow holds and resumes without a refile. This is the platform’s existing answer to an unsatisfiable need, and a placement policy should route into it rather than invent a parallel failure mode.
- TR-01 — every per-tenant binding must be expressible as a version-controlled definition. Whatever decides placement, the result is tracked state; a placement arrived at by unrecorded reasoning is drift by this requirement’s definition.
- TR-54 — every operator-facing surface is bounded by its share of a 2-hour weekly budget. The host-a-capability UX describes the onboarding review scope as deliberately narrow — exactly two questions — so adding a third is a real cost, not a formality.
- TR-19 / TR-20 / TR-21 — engagement is a single append-only thread per lifecycle event, typed by scope, with modify review restricted to the delta. Any placement negotiation has to land on that thread or it does not exist.
- Capability tiebreaker (full chain) — tenant adoption beats reproducibility beats vendor independence beats minimizing operator effort. Adoption at the top is the one term that argues for giving tenants a placement voice; reproducibility immediately below it argues for a rule in the definitions repository over per-tenant judgment.
- Capability Out of Scope — “Dictating the implementation.” “Homelab”, “Kubernetes”, and any specific stack are named as possible implementations of this capability, not part of its definition. Substrate is explicitly not a tenant-facing concept.
- Onboarding UX step 5 precedent — while the operator provisions, the capability owner does “nothing” and is explicitly “not pinged for DNS choices or secrets.” That is the repository’s archetype of a legitimately operator-internal provisioning decision.
- Counter-precedent — the identity choice. Identity is a tenant declaration, made in the tech design and “not a fresh question at onboarding.” A tenant-declared placement would have a working structural model to copy, so the case for Option B is real rather than straw.
- Anti-snowflake rule — provisioning must run the platform’s existing definitions, not hand-rolled per-tenant configuration; bespoke manual config is a Reproducibility-KPI failure, not a tolerable exception.
Considered Options
Option A — Operator judgment at provisioning time
The operator chooses the environment during step 5 of onboarding, alongside DNS names and secrets. Nothing is declared, no review question is added, and the outcome is recorded as a per-tenant binding.
- Satisfies TR-09 trivially — no schema change — and TR-10 without a new gate, since the choice rides on the authorization signal that already exists.
- Best possible position on TR-54: zero added operator-facing surface, and it fits the step-5 precedent exactly.
- Satisfies TR-01 only for the result. The reasoning is nowhere: why a tenant landed in one environment is unrecoverable, so a later operator cannot tell a deliberate placement from an arbitrary one. Over a tenant set this trends toward per-tenant judgment calls, which is the shape the anti-snowflake rule exists to prevent.
- Weak on TR-17: nothing forces the operator to consult the per-environment offering inventory ADR-0001 requires. A placement into an environment lacking a needed offering fails at provisioning rather than at review — precisely the outcome ADR-0001 called “a rejection is a correct outcome, not a failure.”
- Loses the reproducibility tiebreaker term to Option C, which expresses the same policy as a definition.
Option B — The capability owner declares a target environment
Placement becomes a fifth TR-09 declaration, made in the tenant’s tech design following the identity-choice precedent. The operator honors it or refuses it at review.
- Strongest on the tenant adoption tiebreaker term — the top of the chain. A tenant with a genuine environment-specific need gets a channel to express it instead of being routed to a decline.
- Fails TR-09 as currently written: the declaration set grows from four to five, and because the tech design is the contract acceptance, this is a platform-contract change rather than a form field. With live tenants it would require a TR-24 rollout; today it is cheap only because no tenant exists yet.
- Presses on TR-54: adds a third question to a review scope the UX describes as deliberately narrow.
- Contradicts ADR-0001 in a way that cannot be repaired here. A declared placement is a lever the tenant can pull once and never again — migration is not supported, so a tenant that later wants a different environment must be torn down and re-onboarded. Promising something the platform has no supported path to change is worse than not promising it.
- Collides with the capability’s “Dictating the implementation” Out of Scope entry by making substrate a tenant-facing, contractual concept.
Option C — Mechanical resolution from declared needs, with a default and a recorded override
The platform resolves placement from the resource needs the tenant already declares under TR-09, matched against each environment’s offering inventory: exactly one environment qualifies → that one; both qualify → a declared platform default; neither qualifies → the TR-11 hold or a decline. The operator may override, and the override is recorded with its reason.
- Satisfies TR-09 with no schema change and no contract change — it consumes the resource-needs declaration that already exists.
- Directly discharges TR-17 and ADR-0001’s stated obligation: the offering inventory becomes a load-bearing input, and an unsatisfiable placement is refused at review rather than discovered at provisioning.
- Strongest on TR-01 and the reproducibility tiebreaker term: the resolution rule is itself a version-controlled definition, so the placement and the reasoning that produced it are both tracked, and the same inputs reproduce the same placement on a rebuild.
- Good on TR-54: mechanical resolution costs no operator time per tenant, and it folds into step 2’s existing offering-alignment question rather than adding a third one. The override is an exception path, not routine work.
- Routes an unsatisfiable placement into TR-11’s existing hold-and-resume rather than a new failure mode, which is also what BR-64’s evolve-the-platform default demands.
- Cost: makes the per-environment offering inventory a blocking prerequisite — it must exist and be machine-readable before the first onboarding, where ADR-0001 left its timing open.
- Cost: weaker than Option B on tenant adoption, since a tenant with a real environment preference still has no channel to state it.
Option D — One default environment for every tenant
No per-tenant placement exists. Every tenant lands on the public-cloud anchor; the home lab hosts tenants only once it carries an offering the cloud lacks. A tenant the cloud cannot serve is a TR-11 hold or a decline.
- Best possible position on TR-54, TR-01, and TR-02 — there is no per-tenant placement state to define, record, or reproduce.
- Consistent with ADR-0001’s recorded honest driver: managed cloud primitives deliver much of the TR-17 inventory without operator-built machinery.
- Presses hardest on TR-18 and the vendor-independence tiebreaker term by concentrating every tenant’s data in one cloud provider — a concession ADR-0001 already flagged as real.
- Makes ADR-0001’s “both environments are valid tenant-hosting targets” true on paper but inert in practice, with no written path for the home lab to ever start receiving tenants.
Decision Outcome
Chosen option: Option C — mechanical resolution from declared needs, with a default and a recorded override.
It is the only option that satisfies TR-17’s refuse-what-you-cannot-satisfy obligation without spending anything on TR-09’s declaration set, and the only one that puts the reasoning behind a placement inside the TR-01 tracked-changes surface rather than leaving it in the operator’s head. It folds into the existing narrow review scope, so it costs TR-54 nothing per tenant, and it reuses TR-11’s hold rather than inventing a parallel failure path. Option B was rejected because it promises a tenant something ADR-0001 gives the platform no supported way to change; Option A because it satisfies TR-01 for the result but not the reasoning, trending toward exactly the per-tenant judgment the anti-snowflake rule forbids; Option D because it leaves ADR-0001’s second hosting environment with no written path to ever receive a tenant.
The resolution rule
Placement is resolved as a function of the tenant’s declared resource needs and the per-environment offering inventory:
flowchart TD
needs[Tenant's TR-09 declared<br/>resource needs]
inv[Per-environment offering<br/>inventory - ADR-0001]
match{Which environments carry<br/>every offering this tenant needs?}
one[Exactly one qualifies]
both[Both qualify]
none[Neither qualifies]
useit[Place there]
def[Place in the platform default:<br/>public-cloud anchor]
hold[TR-11 hold — evolve the platform<br/>or 3c decline if out of scope]
needs --> match
inv --> match
match --> one --> useit
match --> both --> def
match --> none --> holdThe rule is a version-controlled definition, not operator practice. This is the TR-01 property this ADR fixes: the same declared needs against the same inventory must produce the same placement, on a rebuild as on the original onboarding.
Sub-decision: the default when both environments qualify is the public-cloud anchor
The default has to be some environment, and cloud-first is the only default that is realizable today — ADR-0001’s realization section records the cloud-side tenant-hosting offerings as existing (cloud/rest-api/, cloud/firestore/, and the reachability plumbing) and the home-lab-side offerings as not yet realized. It also matches ADR-0001’s honest operator-effort driver.
This is deliberately the weakest-committed part of the decision. It is a single value in the definitions repository, it binds only tenants for whom both environments are valid — meaning the choice is by construction never the difference between a tenant being hostable and not — and reversing it requires no change to the rule, the declaration set, or the topology. If the home lab later grows enough offering surface to be the better default, flipping it is a one-line change, though per ADR-0001 it applies only to tenants onboarded afterward.
Sub-decision: placement is not part of the platform contract
Placement is an operator-internal detail. A tenant does not declare it, is not told it as a guarantee, and cannot rely on it. This follows the capability’s “Dictating the implementation” Out of Scope entry — substrate is not part of the capability’s definition — and the step-5 precedent, where the operator settles DNS and secrets without pinging the capability owner.
The distinction being drawn is between needs and location: a tenant declares what it needs, and the platform is accountable for meeting those needs (TR-17). Where it meets them is the platform’s business, exactly as the platform’s availability characteristics are something a tenant accepts rather than negotiates.
The concrete consequence is that the resolved placement, though recorded as tracked state under TR-01 and legible to the operator, is not a promise. Nothing in the tenant-facing contract prevents the platform from changing how the rule resolves for future tenants.
Sub-decision: an operator override exists and must be recorded with its reason
The operator may override the resolved placement. Without an escape hatch the rule would become the kind of surface TR-54 warns about — one whose failure mode is unbounded operator work to route around it — and TR-14 makes the operator the only principal on administrative surfaces anyway.
The override is bounded by two conditions that keep it from eroding the decision:
- It may not override a refusal. An override can only choose between environments that both satisfy the tenant’s needs. Forcing a tenant into an environment lacking a needed offering is not an override, it is a TR-17 violation, and the correct outcomes there remain the TR-11 hold or a decline.
- It must be recorded on the engagement thread with its reason, per TR-19 — on the onboarding issue, which is already the channel of record. An unrecorded override is Option A with extra steps, and reintroduces exactly the untracked reasoning that disqualified it.
A recurring override is a signal that the rule or the inventory is wrong, and should be answered by changing the definition rather than by repeating the override.
Sub-decision: the modify flow carries no placement delta
Inherited from ADR-0001, recorded here because it is this ADR’s flows that would otherwise be its home. Since placement is fixed at onboarding and cross-environment migration is unsupported, a TR-21 modify review never surfaces a placement change, and TR-20’s modify issue type needs no placement field.
A modify request can change a tenant’s declared resource needs — and those needs are the rule’s input. This ADR resolves the resulting question explicitly: a modify that would resolve to a different environment does not move the tenant. If the tenant’s new needs can be met in its current environment, they are met there. If they cannot, the outcome is a TR-11 hold to add the missing offering to the environment the tenant is already in — consistent with BR-64’s evolve-the-platform default — or, if that is out of scope, the ordinary decline. Re-resolution on modify would be migration by another name, which ADR-0001 forbids.
Sub-decision: placement is whole-tenant, and this is forced rather than preferred
A tenant’s components are placed together, in one environment. Independent per-component placement is not admissible.
This was originally left open on the grounds that whole-tenant was merely the conservative reading. It is not — ADR-0001 leaves it no alternative. Split placement requires intra-tenant traffic to cross environments, and ADR-0001’s plane separation gives that traffic no lawful path: the operations tunnel carries no tenant application traffic, and the edge is the end-user data plane. Split placement is therefore not a trade-off this ADR declines to make; it is unrealizable under an accepted ADR.
The TR-17 internal-reachability question this raises is broader than placement and is not resolved here. Establishing it exposed that ADR-0001 had mapped TR-17’s internal reachability onto the operations tunnel, when the capability defines internal reachability as reachability between tenants — a different thing. ADR-0001 was amended to correct the mapping and now carries the resulting gap as its own open question. The consequence this ADR inherits: two tenants that must reach each other can only be safely placed in the same environment, and the resolution rule has no way to express that today, because a tenant declares its own needs and not its dependencies on other tenants. This is acceptable while the tenant set is small and no tenant declares such a dependency; the first one that does is the trigger to revisit both ADRs.
Note that the host-a-capability UX reviews offering alignment per component, which is what made per-component placement look plausible. That review granularity is preserved and is not in tension with whole-tenant placement: every component must be satisfiable in the chosen environment, which is exactly the per-component check the UX already describes. Per-component review, whole-tenant placement.
Sub-decision: declared needs are authored machine-readably in the tenant’s own capability docs
The authoritative machine-readable declaration of a tenant’s resource needs lives with the tenant capability’s documentation, authored by the capability owner, and the platform reads it at onboarding.
This keeps BR-13 intact — the tenant declares, in its own design, and the declaration is the contract acceptance — while satisfying TR-01 at no extra cost, because capability documentation and platform definitions already share one tracked-changes repository. There is no transcription step, so the declaration the platform matches against cannot drift from the declaration the tenant made.
The rejected alternatives are recorded for the same reason the main option set is: operator-transcribed into the platform’s definitions was rejected because it makes the operator restate what the tenant declared, straining BR-13 and introducing exactly the divergence risk the shared repository otherwise eliminates; carried on the onboarding issue was rejected because issue state is not a version-controlled definition, so a rebuild could not reproduce the placement from it — a direct TR-01 failure, notwithstanding that TR-19 makes the issue the channel of record for the engagement.
The serialization format is not decided here and remains a tech-design concern. What is fixed is ownership and location: capability-owner-authored, in the tenant’s docs, in this repository. This is a representation obligation, not a change to what TR-09 requires be declared, so it is not a platform-contract change.
Sub-decision: the offering inventory subdivides on demand, starting at TR-17’s inventory
The per-environment offering inventory begins at the granularity TR-17 already names — compute, persistent storage, network reachability, identity, backup/DR, observability — and an entry is subdivided only when a real tenant declaration forces a distinction an environment cannot meet. A tenant needing GPU compute is what splits compute into a general and a GPU-bearing entry; the split does not exist in anticipation of that tenant.
This follows BR-64 — the platform evolves in response to a tenant need rather than ahead of one — and keeps the inventory bounded by TR-54, since every entry is surface the operator maintains. A designed taxonomy fixed up front was rejected as speculative: it grows maintained surface for distinctions no tenant has asked for, against a capability rule that explicitly does not oblige the platform to grow without bound. Letting each environment declare its own offering names at its own grain was rejected because it leaves no shared vocabulary — matching would degrade into string comparison across independently-chosen names, and divergence would be silent rather than caught.
The accepted cost is that the rule stays degenerate longer. At TR-17 granularity both environments either carry an offering or do not, so until a real tenant forces the first subdivision, every placement resolves to the default. That is the same degeneracy already recorded in Consequences, now with a decided mechanism for exiting it: the first tenant whose needs one environment cannot meet is what makes the inventory discriminating, and the TR-11 hold is what absorbs that tenant while the split is made.
Consequences
- Good, because a placement whose target environment lacks a needed offering is refused at review rather than discovered mid-provisioning, discharging the obligation ADR-0001 pushed onto this decision.
- Good, because the resolution rule is a definition rather than operator practice, so both the placement and the reasoning behind it are inside the TR-01 tracked-changes surface and a rebuild reproduces the same placements.
- Good, because it costs the TR-09 declaration set nothing — no fifth declaration, no platform-contract change, no third question added to a review scope the UX deliberately kept narrow.
- Good, because an unsatisfiable placement routes into TR-11’s existing hold-and-resume rather than a new failure mode, keeping BR-64’s evolve-the-platform default as the first response.
- Good, because keeping placement out of the contract avoids promising a property that ADR-0001 leaves the platform no supported way to change.
- Bad, because a tenant with a genuine environment-specific need has no channel to express it. The rule reads needs, not preferences, so such a tenant is routed to a decline that Option B would have avoided — a real cost against the adoption term at the top of the tiebreaker chain. Accepted because the capability currently treats specialized-hardware and regulatory needs as grounds for declining to host at all, so this option removes no channel that exists today; if such a tenant ever appears, that is the trigger to revisit, and BR-64 says the response is to evolve the platform.
- Bad, because the per-environment offering inventory becomes a blocking prerequisite for the first onboarding, where ADR-0001 left its timing open. The placement path cannot resolve anything without it.
- Bad, because today the rule degenerates to its default. No home-lab tenant offerings are realized yet, and “compute, storage, network” is not discriminating enough to separate the environments, so every current tenant resolves to the public-cloud anchor — making this operationally identical to Option D until the home lab carries something the cloud does not. This is accepted knowingly: the rule’s value is that the growth path is decided and written down before the first divergent offering exists, rather than being improvised under the pressure of a tenant that needs it. It also means the rule ships largely unexercised, and its first real test will be its first non-degenerate resolution.
- Bad, because concentrating every current tenant in the public cloud presses on TR-18 and the vendor-independence tiebreaker term, exactly as ADR-0001 recorded. Each managed primitive admitted as a tenant-hosting offering must still pass TR-18 on its own.
- Requires: a per-environment offering inventory, machine-readable and version-controlled, that the resolution rule consults — the input ADR-0001 named and this ADR makes blocking.
- Requires: the tenant’s declared resource needs to be captured in a form the rule can evaluate against that inventory, rather than as prose in a tech design. This is a TR-09 representation obligation on the tech design, not a change to what is declared.
- Requires: the resolved placement to be persisted as a per-tenant binding in the definitions repository (TR-01), so provisioning and any later rebuild consume it rather than re-deriving it.
- Requires: the onboarding engagement thread to carry the override and its reason when one is exercised (TR-19).
Realization
- Definitions repository (top level, alongside
cloud/) — the per-environment offering inventory and the resolution rule live here as tracked definitions, not as service code. Placement is decided at onboarding by the operator running the platform’s definitions; it is not a runtime request path, so no service underservices/owns it and no HTTP endpoint, protobuf message, orpkg/errorpbproblem type is introduced by this decision. - Per-tenant bindings — the resolved placement is recorded as part of the tenant’s version-controlled binding, consumed by provisioning and reproduced on rebuild.
cloud/rest-api/,cloud/firestore/,cloud/https-load-balancer/,cloud/internal-application-load-balancer/,cloud/ip/,cloud/dns/— the public-cloud anchor’s tenant-hosting offerings, and therefore the cloud side’s entries in the offering inventory the rule reads.- Home-lab definitions surface (peer to
cloud/, tooling undecided per ADR-0001) — must declare what it offers so the rule can resolve against it. Until it declares a tenant-hosting offering, it qualifies for no tenant and the rule resolves to the default for every tenant. cloud/mtls/cloudflare-gcp/— unchanged by this decision, and the reason it is unchanged is load-bearing: ADR-0001’s rule that placement changes where a workload runs and never how it is reached means the edge path is identical for both resolutions.tech-design.md(composed later byplan-tech-design) will fold the inventory, the resolution rule, and the per-tenant binding into the onboarding-flow narrative.
Open Questions
None remain in this ADR’s scope. The three questions it opened on 2026-07-18 were resolved the same day and folded into Decision Outcome as sub-decisions.
One question left this ADR’s scope rather than closing: resolving placement granularity established that ADR-0001 had misread TR-17’s internal reachability, and the real gap — tenant-to-tenant reachability across environments — is topology-altitude. ADR-0001 was amended to correct the mapping and now carries that gap as its own open question. What this ADR inherits from it is a constraint, not a question: two tenants that must reach each other are only safely placed in the same environment.
Resolved
- Placement granularity. → Whole-tenant, and forced rather than preferred. Split per-component placement would require intra-tenant traffic to cross environments, which ADR-0001’s plane separation gives no lawful path. Per-component review is preserved; per-component placement is not admissible (sub-decision).
- Representation of declared needs. → Machine-readable, authored by the capability owner, in the tenant capability’s own docs. Keeps BR-13 intact and satisfies TR-01 without a transcription step, because capability docs and platform definitions share one repository. Serialization format stays with the tech design (sub-decision).
- Offering-inventory granularity. → Starts at TR-17’s inventory; subdivides only when a real tenant declaration forces a distinction. Follows BR-64 and stays bounded by TR-54; the accepted cost is that the rule remains degenerate until the first such tenant, absorbed by the TR-11 hold (sub-decision).
- Who decides. → The platform, mechanically, from declarations the tenant already makes. Not the operator’s per-tenant judgment (Option A, rejected for leaving the reasoning untracked) and not the capability owner (Option B, rejected for promising a property ADR-0001 cannot let the platform change).
- On what basis. → Declared resource needs matched against the per-environment offering inventory, with the public-cloud anchor as the default when both environments qualify and an operator override bounded to cases where both already satisfy the tenant.
- What is promised. → Nothing. Placement is an operator-internal detail on the same footing as DNS names and secrets. The tenant declares needs and the platform is accountable for meeting them; where it meets them is not contracted.
- When. → Inherited from ADR-0001, not decided here: at onboarding, durably. This ADR adds the corollary that a modify request changing a tenant’s declared needs does not re-resolve placement — the missing offering is added to the tenant’s current environment, or the request is declined.
1.2.3 - [0003] Definitions Repository & Tracked-Changes Substrate
Zaba505/infra; private per-environment bindings, values, and apply workflows in Zaba505/homelab — joined by a module reference that becomes a tracked definition rather than a hand-typed dispatch input, so the private repository’s main is a single reference sufficient to reproduce the platform.Parent capability: Self-Hosted Application Platform Addresses requirements: TR-01, TR-04, TR-05, TR-02, TR-18, TR-54, TR-06, TR-07
Context and Problem Statement
TR-01 makes a tracked-changes definitions repository the only authoritative surface for platform-modifying writes: every offering, every per-tenant binding, every shared piece of configuration must be expressible there, and anything modifiable outside it is drift. TR-04 requires each rebuild phase to expose a deterministic, definitions-driven teardown. TR-05 requires a preflight drift check against a last-known-good reference that fails closed.
None of the three name a repository, a mechanism, or a form for that reference. This ADR decides all three, because every later offering’s definitions surface sits on top of the answer.
Both accepted siblings have already assumed a substrate without deciding one. ADR-0001 fixed the home-lab foundations as “version-controlled definitions in the same tracked-changes repository, as a peer top-level surface alongside cloud/”. ADR-0002 rested its declared-needs sub-decision on the claim that “capability documentation and platform definitions already share one tracked-changes repository”, using that to eliminate a transcription step. Those assumptions are load-bearing and this ADR must either honor or explicitly reconcile them.
What exists today
The substrate is not greenfield. It is a module / root-module split across two repositories, and the seam is already drawn cleanly at the content level:
| Layer | What it is | Lives in | Secret-bearing |
|---|---|---|---|
| Modules | reusable, fully parameterized components | Zaba505/infra (public) — cloud/* | no |
| Declarations | a tenant’s TR-09 ask | Zaba505/infra (public) — capability docs, per ADR-0002 | no |
| Bindings | root modules, terragrunt.hcl, per-env terraform.tfvars, apply workflows | Zaba505/homelab (private) | secret references, not values |
| Applied state | Terraform state | GCS bucket, project infra-home-lab | yes — plaintext, routinely |
| Secret values | — | Secret Manager, per TR-44 | yes |
Three facts about this arrangement were verified rather than assumed, and each bears on the decision:
- The public repository is clean.
cloud/*contains no real domain, IP, or project identifier — every module is fully parameterized and every real value arrives astfvarsfrom the private repository. The seam is not aspirational; it is holding. - The repositories are joined by a module reference. The private repository’s root
terragrunt.hclsources modules asgit::https://github.com/Zaba505/infra.git//${TF_INFRA_MODULE_PATH}?ref=${TF_INFRA_MODULE_REF}. - That reference is untracked. Each per-component workflow supplies it as
TF_INFRA_MODULE_REF: ${{ inputs.module_ref }}— aworkflow_dispatchinput typed by hand at dispatch time, per component. It is recorded in no definition, in either repository.
The third fact is the problem this ADR exists to fix. Which version of the platform’s modules gets applied is the most load-bearing value in any apply, and it lives only in a workflow run record. That is TR-01’s definition of drift verbatim, and it breaks TR-02 concretely: the operator whom the standup UX describes as arriving at minute zero with “the definitions repo, pulled fresh” cannot reproduce the platform from it, because the definitions do not contain the refs. They would be re-typing remembered values, per component, with nothing recording which combination was ever known-good.
The decision is capability-scoped: it fixes where this capability’s definitions live and what gate owns mutations to them. It does not decide the engagement channel (TR-19/TR-20), which is its own decision, nor how drift comparison is performed, which is the downstream drift-detection ADR’s.
Decision Drivers
- TR-01 — the definitions repository is the only authoritative surface for platform-modifying writes. Any value that determines what gets applied and lives outside it is drift by definition, not by degree.
- TR-02 — a fresh pull must be sufficient to drive an end-to-end rebuild inside 60 minutes. A substrate that requires remembered out-of-band inputs fails this before the clock starts.
- TR-05 — the preflight check needs a last-known-good reference it can compare against and fail closed on. A reference that is a tuple of independently-moving values is materially weaker than a single one, because no atomic commit binds the members together.
- TR-04 — per-phase teardown must be deterministic and definitions-driven. Teardown depends on applied state, so where that state lives is part of this decision rather than an implementation detail.
- TR-18 — every third-party component must allow config control, portable export, and credential rotation without vendor cooperation. Git scores near-perfectly: every clone is a complete export and the forge is replaceable. Forge-specific state is the residual.
- TR-54 — every operator-facing surface is bounded by its share of a 2-hour weekly budget. Each additional repository is another ruleset, another CI surface, and another thing to keep current.
- TR-14 — administrative surfaces admit only the operator. A private binding repository requires a standing write grant to anyone else who must contribute; a public one does not, because fork-and-PR needs no grant at all.
- ADR-0001 constraint — home-lab definitions are a peer surface, not a second-class hand-managed environment, with a per-phase teardown entry point.
- ADR-0002 constraint — tenant declarations are authored by the capability owner in the tenant capability’s own docs, with no transcription step. Any option that separates declarations from capability docs reintroduces the divergence that sub-decision rejected by name.
- Capability tiebreaker — tenant adoption beats reproducibility beats vendor independence beats minimizing operator effort. Reproducibility is the operative term here and it argues for a single sufficient reference.
- Confidentiality of aggregated operational data (not TR-derived — recorded as an honest driver). See the privacy sub-decision; the honest form of this driver is narrower than it first appears, and the ADR records the narrow form so it does not decay into security-by-obscurity.
Considered Options
Option A — Keep the split; make the module reference a tracked definition
Retain the two repositories and the content seam exactly as they stand. Change one thing: the private repository commits the infra module reference as a definition instead of accepting it as a workflow_dispatch input.
- Discharges the TR-01 violation directly and at its actual location — the untracked value moves inside the tracked-changes surface.
- Satisfies TR-02: a fresh pull of the private repository becomes sufficient, since
homelab@Xtransitively determinesinfra@Y. Nothing must be remembered. - Strongest realistic position on TR-05: the last-known-good reference collapses from a tuple to a single commit SHA on one repository.
- Costs TR-54 almost nothing — one committed value, no new repository, no new ruleset, no new CI surface.
- Honors ADR-0002 without amendment (declarations stay in capability docs) and ADR-0001 at the module layer (see the reconciliation note).
- Preserves the confidentiality position for the reasons recorded in the privacy sub-decision.
- Cost: the two repositories still exist, so TR-18’s forge-portability story must be told twice, and a contributor to declarations (public) is a different flow from a contributor to bindings (private).
- Cost: does not by itself close the ~20-month staleness gap between the repositories (recorded under Consequences).
Option B — Status quo: keep the split, keep the reference at dispatch
Change nothing.
- Fails TR-01 on the module reference — the value that determines what is applied is modifiable, and only modifiable, outside the repository.
- Fails TR-02’s pull-fresh-and-rebuild property: the definitions are insufficient, and the shortfall scales with the component count.
- Fails TR-05: there is no reference to compare against, so a preflight check has nothing to fail closed on. The check could not be built on this substrate at all.
- Best possible TR-54 position (zero change), which is the only thing it wins on.
- Recorded so the rejection is auditable rather than implied.
Option C — Vendor the modules into the binding repository
Drop the cross-repository source entirely; copy cloud/* into the private repository so one repository fully determines an apply.
- Strongest possible TR-05 and TR-02 position: a single repository, single clone, no cross-repository reference of any kind.
- Satisfies TR-01 trivially — there is nowhere else for a value to hide.
- Costs the public module surface and its reuse;
cloud/*maintenance would be duplicated or abandoned. - Contradicts ADR-0002. Declarations live in capability docs; if the binding repository is the sole substrate, either declarations move private (reintroducing the transcription step that sub-decision rejected) or the substrate is two repositories again and the option collapses into A.
- Presses on TR-54: vendored modules drift from upstream unless a sync ritual is maintained, and that ritual is operator work with no natural forcing function.
Option D — Collapse everything except docs into one private repository
Modules, declarations, bindings, and workflows all move private; only the published Hugo docs remain public.
- Single reference, single ruleset, single clone — strong on TR-05, TR-02, and TR-54.
- Contradicts ADR-0002 the same way Option C does, and more directly: declarations become private, so either capability owners receive standing write grants on the platform’s binding repository or the operator transcribes their declarations. The first strains TR-14’s posture; the second is exactly what ADR-0002 rejected.
- Loses the public module surface, which is the part of the current arrangement most clearly working.
- Would require an accepted ADR to be amended to proceed, which is a real cost and not merely procedural.
Decision Outcome
Chosen option: Option A — keep the split; make the module reference a tracked definition.
It is the only option that fixes the actual TR-01 violation while changing no content, requiring no new surface against TR-54, and contradicting no accepted sibling. Options C and D both buy a marginally stronger TR-05 reference by breaking ADR-0002’s no-transcription sub-decision — paying an accepted decision’s coherence for a property Option A already obtains by committing one value. Option B is disqualified outright: it does not merely score poorly on TR-05, it makes the required preflight check unbuildable, since there is nothing to compare against.
The substrate
flowchart TD
subgraph pub["Zaba505/infra — public"]
mods["cloud/* — reusable modules<br/>fully parameterized, no real values"]
docs["capability docs<br/>+ tenant TR-09 declarations"]
end
subgraph priv["Zaba505/homelab — private"]
pin["committed module ref<br/>(the tracked pin)"]
binds["root modules, terragrunt.hcl,<br/>per-env tfvars, apply workflows"]
end
state[("Terraform state<br/>GCS, in-environment<br/>derived, never committed")]
sec[("Secret Manager<br/>values, referenced by name")]
pin -->|"determines"| mods
binds --> state
binds -.->|"by name only"| sec
priv -->|"main@SHA = last-known-good"| ref((single sufficient<br/>reference))Sub-decision: the module reference is a tracked definition, not a dispatch input
The private repository commits the infra module reference. TF_INFRA_MODULE_REF ceases to be a workflow_dispatch input supplied by hand and becomes a value read from the repository.
This is the whole point of the ADR. With it, the private repository’s main at a given commit fully determines what an apply produces; without it, no amount of structure elsewhere makes the definitions sufficient. It is also what makes TR-05’s preflight check possible rather than merely unimplemented.
Granularity: a single repository-wide pin, not per-component. The reference is one value covering every component, rather than eleven independently-set ones. Per-component pins would reconstruct precisely the tuple problem this decision exists to collapse — a rebuild could combine refs that were never validated together, and the last-known-good reference would again have no atomic representation.
The accepted cost is that a module change cannot be rolled out component-by-component: bumping the pin moves every component to the new modules at once. Staged rollout is available by pinning a branch of infra that carries only the intended change, which keeps the value single and tracked. Divergence by editing individual components’ refs is a deliberate escape from this sub-decision and, if ever exercised, must be recorded in-repo rather than typed at dispatch.
Sub-decision: the private repository’s main is the authoritative reference; last-known-good is a commit SHA
main in the binding repository — not any branch, working copy, or applied environment — is what the definitions are. The last-known-good reference for TR-05 is a commit SHA on that branch: specifically the SHA at which a rebuild’s canary (TR-07) last went green.
The existing ruleset supplies the immutability BR-51 asks for, at no cost: required signatures, required linear history, no force-push, no deletion. A commit SHA under those rules is a tamper-evident pointer to an exact definitions state, and git’s content addressing means comparison needs no separate manifest format.
What is fixed here is the form of the reference. How the SHA is captured on a green canary, where it is recorded, and how comparison against live infrastructure is performed are the downstream drift-detection ADR’s to decide. This ADR’s obligation was to make a single sufficient reference exist.
Sub-decision: applied state is derived, lives in the target environment, and is never committed
Terraform state is not a definition. It is a derived cache of the mapping between definitions and real resources, and this ADR classifies it as such: TR-01’s “entirely expressible as version-controlled definitions” is satisfied by layers 1–3 of the table above, and state is downstream of them.
State stays where it is — a GCS bucket in the target environment, per-component-prefixed — and is never committed to either repository. Two independent reasons, either sufficient: Terraform state routinely contains secret material in plaintext, and a drill (TR-06) runs the same definitions against different infrastructure, which is only coherent if state is a property of the target rather than of the definitions.
The consequence to carry forward is that TR-04 teardown depends on state durability. A phase’s deterministic teardown is driven by definitions but executed against state; losing state does not merely inconvenience a teardown, it strands resources the definitions can no longer address. State durability is therefore a teardown correctness concern, not an operational nicety.
Sub-decision: the state backend is bootstrapped by a definitions-driven phase 0 that adopts its own state
The sub-decision above creates a chicken-and-egg: the bucket holding applied state cannot be managed by the state it holds. It is resolved here rather than deferred, because the state-location decision is what creates it.
The bootstrap is a definitions-driven phase 0. A bootstrap root module in the binding repository — committed like any other definition, not a hand-run command — creates the state bucket with versioning and retention. It runs against a local backend on its first execution and then migrates its own state into the bucket it just created, after which it is an ordinary component managed exactly like every other. The chicken-and-egg is discharged once, at the only moment it exists.
Committing the bootstrap’s state to the binding repository instead was considered and rejected: a bucket-only state is unlikely to carry secret material, but the never-committed rule above admits no per-case exceptions without becoming a judgment call at every future component.
It is a new phase, and it counts against the TR-02 budget. The standup UX enumerates four phases beginning at foundations and has no provisioning step before the preflight check; phase 0 precedes TR-03’s foundations phase and is a required change to that UX. Excluding it from the 60-minute budget was considered and rejected: the budget is a target rather than a gate — missing it files a follow-up issue and does not stop the platform going into service — so exclusion protects nothing, while it would let the reproducibility KPI be measured against a rebuild that was not actually from scratch.
It is parameterized by target, which is what makes TR-06 literally true. A drill creates its own scratch bucket by running the same phase 0 against a different target. Without this, “drill mode and live mode differ only in the underlying target” would be true of everything except the one resource the drill most needs to not share.
Its TR-04 teardown exists but carries an ordering constraint: phase 0 tears down last, after every phase whose state it holds. Torn down out of order it strands precisely what the state-durability consequence above warns about. “Delete everything provisioned so far and start over” stays viable at every checkpoint; what is fixed here is the order in which that unwinds.
The residual is real and recorded: after adoption, the bootstrap module’s own state lives in the bucket that module manages. Losing the bucket therefore also loses the record of how to rebuild it. It is recoverable — the bucket is re-creatable and re-importable by re-running phase 0 — but recovery is a manual exception rather than the ordinary path.
Sub-decision: secrets live in Secret Manager and are referenced by name in both repositories
No secret value is committed to either repository, including the private one. Definitions carry references; TR-44’s secret-management surface carries values.
Stated explicitly because the private repository’s privacy makes the weaker discipline tempting, and because the privacy sub-decision below leans on secrets being absent from both surfaces rather than on one surface being unreadable.
Sub-decision: mutations are gated by pull request into main, under the existing ruleset
The tracked-changes mechanism is git plus the forge’s pull-request gate, under the ruleset already active on Zaba505/infra and to be matched on Zaba505/homelab: required signatures, required linear history, no force-push, no branch deletion, pull request required with zero required approving reviews — the last of which is what makes the mechanism viable for a platform whose TR-14 posture admits exactly one principal. A gate requiring a second reviewer would be unsatisfiable by construction.
The pull request is what gives TR-05’s fail-closed check, terraform fmt, and any future validation a place to attach before a change becomes authoritative. Direct signed commits to main were considered and rejected for exactly this: they satisfy TR-01 in form — still version-controlled, still signed, still immutable — while leaving no pre-merge point at which anything can be checked, and no proposed-versus-accepted artifact for an engagement thread to reference.
The administrative bypass is retained. The ruleset’s admin bypass (bypass_mode: always) stays in place as an emergency escape hatch, and the discipline of routing changes through the gate is a matter of operator convention rather than enforcement.
This is a deliberate choice and its cost is recorded rather than minimized: the operator can mutate main outside the gate, so TR-01 is enforced by convention rather than by construction, and TR-05’s preflight check cannot distinguish a bypassed change from genuine drift. Every bypass is indistinguishable from the failure mode the check exists to catch. If a bypass is ever exercised, that is the trigger to revisit this sub-decision — either by removing the bypass or by adopting the recorded-override discipline ADR-0002 uses for placement.
Sub-decision: the public/private seam is justified by aggregation, not obscurity
The seam is retained as it stands — modules, docs, and declarations public; bindings, per-environment values, and workflows private. The rationale matters as much as the outcome, because the obvious rationale is wrong.
Obscurity is explicitly not load-bearing. Domain names and public IP addresses are public by construction: Certificate Transparency publishes every hostname for which a publicly-trusted certificate is issued, and CT logs are monitored continuously; full-IPv4 scanning is cheap and constant. A hostname fronted by the edge is discoverable within minutes of certificate issuance whether or not it appears in a repository. An ADR recording “bindings are private so crawlers do not find the hosts” would be false on the day it was written.
Three narrower rationales do survive scrutiny, and they are the recorded basis:
- Aggregation. CT tells a scanner that a hostname exists. The binding repository would tell it the full stack, the wiring, which tenant sits where, and what is behind each name — assembled, with no reconnaissance. Each fact is individually discoverable; the collection is not.
- Pre-disclosure. A merged definition names a host before apply — before its defenses exist. CT cannot leak a certificate that has not been issued.
- Mistake blast radius. The binding layer carries secret references and may one day carry a mistake. In a public repository an accidentally-committed credential is harvested within seconds and is permanently public; in a private one it is a recoverable incident.
The actual control remains architectural, not confidential: per ADR-0001 the origin refuses any request that does not present an edge-issued client certificate, so traffic reaching an origin directly fails at the TLS layer. Discovery buys an attacker noise, not access — conditional on that posture holding, which ADR-0001 requires of both environments and which the home-lab side has not yet realized.
Sub-decision: forge-held non-git state is admissible under TR-18; rulesets are the live gap
Git gives TR-18 a near-vacuous answer for the definitions themselves, but the forge also holds state that is not git. That state is three unlike things, and TR-18’s three limbs must be applied to each separately rather than to “the forge” as a lump.
Rulesets are configuration, and they are the actual gap. TR-18’s limb (a) requires configuration control through the platform’s tracked-changes surface — not merely that the operator can change it. Rulesets are set through the forge’s web UI and recorded in no definition, which is drift by TR-01’s plain reading, the same failure class as ADR-0001’s residual risk 3.
This one is worth stating sharply because it is self-referential: the ruleset that enforces this ADR’s pull-request gate is itself an untracked value. The mechanism the substrate leans on for tamper-evidence is configured the same way the module reference was, and is drift for the same reason. Expressing both repositories’ rulesets as definitions is therefore an obligation of this decision, not a downstream nicety.
Workflow run history is derived, and this ADR is what makes that true. It was load-bearing only because the module reference lived in it and nowhere else — the violation this ADR exists to fix. Once the pin is a tracked definition, run history is operational telemetry downstream of the definitions, in the same class as applied state but without state’s teardown role. It carries no export obligation.
Issues are data of record, and limb (b) passes. The TR-19/TR-20 engagement thread is genuinely platform-held data, and the operator can export it in portable form through a documented API with no vendor approval, negotiation, or plan change. “Without vendor cooperation” means without needing the vendor’s permission — not without using the vendor’s interfaces. The stricter reading is rejected on consistency grounds as much as on merit: it would retroactively disqualify the edge vendor that ADR-0001 already admitted, since that vendor’s configuration is likewise driven through its API. An accepted sibling cannot be invalidated by a reading adopted here in passing.
What limb (b) demands is that the export be routine rather than theoretical. An export path that has never been run is an assumption, not a capability.
Scope discipline. This settles the forge’s admissibility as the substrate’s host — the question this ADR opened. It does not choose the engagement channel, which remains its own decision. What that decision inherits is a narrowed constraint rather than an open question: whatever channel it selects must carry an operator-executable export of the thread, and if it selects this forge it inherits the ruleset obligation above as well.
Reconciliation sequencing: clean plans per component, then one drill
The binding repository’s ~20-month staleness must be closed before TR-05’s preflight check can produce a meaningful result — but closing it is itself a large tracked change, and the check that would validate it does not yet work. The resolution splits the problem in two rather than choosing between evidence and tractability.
Stage 1 — clean-plan reconciliation, component by component. A terraform plan that proposes no changes against live infrastructure is a drift check for that component: it demonstrates the definitions and reality agree. This needs no new machinery and no scratch infrastructure, and it decomposes — each component is a separate tracked change, separately reviewable, rather than one enormous diff whose failure modes cannot be localized. Removal or migration of the legacy terraform/ tree happens here.
The limit is important and is the reason stage 2 exists: a clean plan reconciles the definitions to what is, not to what should be. Drift that is itself wrong gets ratified into the definitions by a process that only ever drives the diff to zero. Each plan’s revealed differences must therefore be inspected and judged, not merely eliminated — the operator is deciding, per difference, whether reality or the definition is correct.
Stage 2 — one full drill establishes the baseline. With every component planning clean, a single TR-06 drill runs the rebuild end-to-end against scratch infrastructure, and the TR-07 canary going green stamps the first last-known-good SHA. This is what makes the initial TR-05 baseline demonstrated rather than asserted. Accepting the reconciliation on inspection alone was rejected for exactly that: it would poison the baseline at the moment it is established, and every later drift check would measure against a reference no one had ever validated.
Ordering. Stage 2 depends on the phase-0 bootstrap being parameterized by target, since the drill needs its own state bucket — which is why the bootstrap sub-decision sequences ahead of reconciliation. Reconciliation is deliberately not gated on the home-lab module surface, which does not exist yet: waiting for it would hold the TR-05 baseline hostage to a build-out that is separate work.
Reconciling ADR-0001’s “peer surface” with the seam
ADR-0001 required home-lab foundations to be “version-controlled definitions in the same tracked-changes repository, as a peer top-level surface alongside cloud/”. That wording predates this ADR naming the seam, and read literally against a two-repository substrate it is ambiguous. This ADR resolves the ambiguity without weakening what ADR-0001 decided:
- Home-lab modules are a peer top-level surface alongside
cloud/in the public repository — same repository, same standing, exactly as ADR-0001 required. - Home-lab bindings follow the same seam as every other component’s bindings: private repository, per-environment values, apply workflow.
The property ADR-0001 was protecting — that the home lab not become a hand-managed, second-class environment outside the tracked-changes surface — is fully preserved, since both layers are tracked definitions. What changes is only that “the same repository” resolves per layer rather than per environment, which is the seam this ADR decides. ADR-0001’s tooling-undecided caveat is untouched.
This also gives ADR-0001’s residual risk 3 (the home-lab tunnel endpoint configured out-of-band, via GUI, documented as prose) a decided destination: it is drift until it is expressed as a module in the public repository and bound in the private one.
Consequences
- Good, because the module reference — the single most load-bearing value in any apply — moves inside the tracked-changes surface, closing a TR-01 violation at its actual location rather than compensating for it elsewhere.
- Good, because a fresh pull of the binding repository becomes sufficient to rebuild, which is what TR-02’s 60-minute budget presumes and what the standup UX describes the operator as having in hand at minute zero.
- Good, because TR-05’s last-known-good reference collapses from a tuple to a single commit SHA, and the existing ruleset makes that SHA tamper-evident for free — no manifest format, no signing scheme, no new machinery.
- Good, because it changes no content and contradicts no accepted sibling, where the two single-repository options both required an accepted ADR to be amended.
- Good, because git gives TR-18 a near-vacuous answer for the definitions themselves: every clone is a complete export and the forge is replaceable without vendor cooperation.
- Bad, because TR-01 remains enforced by convention rather than by construction. The retained administrative bypass means the operator can mutate
mainoutside the gate, and TR-05’s preflight cannot tell a bypassed change from drift. This is accepted knowingly; the first exercised bypass is the trigger to revisit. - Bad, because a single repository-wide pin means module changes roll out to every component at once. Staged rollout is available only by pinning a purpose-made branch, which is more ceremony than editing one component’s ref would be.
- Bad, because two repositories mean two rulesets, two CI surfaces, and two contribution flows — a public fork-and-PR path for declarations, a private grant-based path for bindings — all of which is TR-54 surface that a single repository would not spend.
- Bad, because the binding repository is roughly 20 months stale (last pushed 2024-11-16) relative to the modules it pins, and it retains a legacy
terraform/tree predating the Terragrunt layout. The first preflight drift check under TR-05 will therefore run against a large accumulated gap, and reconciling it is real work that this decision creates rather than resolves. - Bad, because TR-04 teardown now visibly depends on applied-state durability: losing state strands resources the definitions can no longer address. Classifying state as derived is correct, but it does not make teardown independent of it.
- Good, because the forge’s non-git state was tested against TR-18 limb by limb and cleared, converting an open risk into one named obligation (rulesets as definitions) and one narrowed constraint handed to the engagement-channel ADR (a routine thread export).
- Good, because the phase-0 bootstrap is parameterized by target, which makes TR-06’s “drill and live differ only in the underlying target” true of applied state as well — the drill no longer needs a hand-made bucket to be honest.
- Good, because reconciliation decomposes into per-component clean plans rather than one large diff, so the work is separately reviewable and needs no scratch infrastructure until the final baseline drill.
- Bad, because the ruleset enforcing this ADR’s own pull-request gate is untracked configuration — the substrate’s tamper-evidence rests on a value configured the same way the module reference was. Recorded as an obligation rather than resolved, and it compounds the bypass cost above: neither the gate nor its configuration is enforced by construction today.
- Bad, because phase 0 adds a rebuild phase the standup UX does not have, and its teardown carries an ordering constraint the other phases do not — phase 0 must tear down last, so “delete everything and start over” is order-sensitive in a way TR-04’s flat per-phase framing does not anticipate.
- Bad, because after adoption the bootstrap module’s state lives in the bucket it manages, so losing the bucket loses the record of how to rebuild it. Recovery is re-running phase 0 and re-importing — possible, but a manual exception rather than the ordinary path.
- Bad, because clean-plan reconciliation drives the diff to zero against current reality, which ratifies any drift that is itself wrong unless each revealed difference is judged rather than merely eliminated. The stage-2 drill bounds this but does not remove it — the operator’s inspection is load-bearing.
- Requires: the module reference committed to the binding repository, and every per-component workflow changed to read it rather than accept a
workflow_dispatchinput. - Requires: a phase-0 bootstrap root module in the binding repository that creates the state bucket, adopts its own state into it, and is parameterized by target; plus a standup-UX change introducing phase 0 ahead of foundations, with its teardown documented as last-out.
- Requires: both repositories’ rulesets expressed as definitions rather than set through the forge’s UI, closing the self-referential TR-01 gap on this ADR’s own enforcement gate.
- Requires: the engagement thread’s export path exercised at least once, so TR-18 limb (b) rests on a demonstrated capability rather than a documented one.
- Requires: the
Zaba505/infraruleset (signatures, linear history, no force-push, no deletion, PR with zero required approvals) replicated onZaba505/homelab, which has no equivalent today. - Requires: reconciliation of the binding repository against current
cloud/*, including removal or migration of the legacyterraform/tree, before the first TR-05 preflight check can be meaningful — sequenced as per-component clean plans followed by one baseline drill, per the reconciliation note. - Requires: a home-lab module surface in the public repository, peer to
cloud/, per the reconciliation note — the destination for ADR-0001’s residual risk 3. - Requires: the first last-known-good SHA to be stamped by a green canary on a full drill, not asserted on inspection — so TR-05’s baseline is demonstrated from the outset.
Realization
Zaba505/infra(public) —cloud/*reusable modules; capability docs including tenant TR-09 declarations per ADR-0002;pkg/andservices/unchanged by this decision. Gains a home-lab module surface peer tocloud/.Zaba505/homelab(private) — root modules and the Terragrunt layout ({component}/{env}/terragrunt.hcl+terraform.tfvars, with optional{region}/terraform.tfvars), per-component apply workflows, and the committed module pin this ADR introduces. Itsmainis the platform’s authoritative reference.terragrunt.hcl(repository root of the binding repo) — the seam’s mechanism:source = "git::https://github.com/Zaba505/infra.git//${module_path}?ref=${module_ref}", withmodule_refbecoming a tracked value. Also generates the backend and provider configuration.- GCS state bucket, project
infra-home-lab— applied state, per-component prefixed viapath_relative_to_include(). Outside both repositories by decision, and a TR-04 teardown dependency. Created by phase 0 rather than assumed present. - Phase-0 bootstrap root module (binding repository) — new. Creates the state bucket with versioning and retention, adopts its own state into it on first run, and is parameterized by target so drills bootstrap scratch state. Tears down last.
- Ruleset definitions for both repositories — new. The signatures / linear-history / no-force-push / no-deletion / PR-required configuration expressed as definitions rather than set through the forge’s UI, on
Zaba505/infraandZaba505/homelabalike. - Secret Manager — secret values, referenced by name from both repositories; already the destination for the origin keypair and trust anchor per
cloud/mtls/cloudflare-gcp/. cloud/mtls/cloudflare-gcp/— ADR-0001’s residual risk 2 (trust anchor fetched live from the vendor at apply time) now has a decided home: the anchor belongs vendored in the public module surface with a tracked update path. A vendored copy already exists in the binding repository’s legacyterraform/load-balancer/tree, on the wrong side of the seam.tech-design.md(composed later byplan-tech-design) will fold this substrate into the rebuild-flow narrative alongside the other accepted ADRs.
Open Questions
None remain. The three questions this ADR previously carried are resolved and folded into the sections above. What is handed downstream is not a set of open questions but a set of constraints already decided here:
- The rebuild-orchestrator ADR inherits phase 0 as a decided phase — definitions-driven, target-parameterized, inside the TR-02 budget, torn down last — rather than a chicken-and-egg to solve. It still owns how the phases are sequenced and checkpointed.
- The drift-detection ADR inherits a last-known-good reference whose form is fixed (a commit SHA) and whose first value has a decided provenance (stamped by a green canary on the stage-2 drill). It still owns capture and comparison.
- The engagement-channel ADR inherits a constraint, not a question: whatever channel it picks must carry an operator-executable export of the thread, and if it picks this forge it inherits the ruleset obligation too. It still owns the channel choice.
Resolved
- Which repository holds platform state. → Two, with a decided seam. Public: reusable modules, capability docs, tenant declarations. Private: root modules, per-environment values, apply workflows. The seam falls between reusable and parameterized and specific and bound — not between “platform” and “tenant” (Decision Outcome).
- What tracked-changes mechanism owns mutations. → Git plus a pull-request gate into
main, under a ruleset requiring signatures and linear history and forbidding force-push and deletion, with zero required approving reviews so a single-operator platform can satisfy it. Direct-to-mainsigned commits were rejected for leaving no pre-merge point for validation to attach to (sub-decision). - The untracked module reference. → Committed as a definition, single repository-wide pin. The value that determines what gets applied moves inside the tracked-changes surface, making a fresh pull sufficient to rebuild. Per-component pins were rejected for reconstructing the tuple this decision collapses (sub-decision).
- Form of TR-05’s last-known-good reference. → A commit SHA on the binding repository’s
main— the SHA at which a rebuild’s canary last went green. The ruleset makes it tamper-evident without additional machinery. Capture and comparison remain the drift-detection ADR’s (sub-decision). - Where applied state lives. → In the target environment, never committed. State is derived, not a definition; it contains plaintext secret material; and drill-versus-live parity under TR-06 only works if state is a property of the target. The cost is that TR-04 teardown depends on state durability (sub-decision).
- Administrative bypass of the gate. → Retained; discipline is convention. Accepted with its cost recorded in full: TR-01 is enforced by convention rather than construction, and TR-05 cannot distinguish a bypass from drift. The first exercised bypass is the trigger to revisit (sub-decision).
- Why the private side is private. → Aggregation, pre-disclosure, and mistake blast radius — explicitly not obscurity. Hostnames and IPs are public by construction via Certificate Transparency and continuous IPv4 scanning; the recorded rationale is narrowed accordingly so it does not decay into security-by-obscurity (sub-decision).
- State-backend bootstrap (TR-02, TR-04). → A definitions-driven phase 0 that adopts its own state, counts against the budget, and tears down last. A committed bootstrap module creates the bucket against a local backend, then migrates its state into the bucket it created. It is parameterized by target, which is what makes TR-06’s drill-versus-live parity true of applied state. Exclusion from the 60-minute budget was rejected because the budget is a target rather than a gate, so exclusion protects nothing while letting the KPI be measured against a rebuild that was not from scratch (sub-decision).
- Forge-held non-git state under TR-18. → Admissible, on a three-way classification. Rulesets are configuration and are the live gap — untracked today, and self-referentially so, since the ruleset enforcing this ADR’s gate is itself undefined in any definition. Workflow run history is derived and carries no export obligation, a classification this ADR’s own pin makes true. Issues are data of record and pass limb (b), because “without vendor cooperation” means without the vendor’s permission, not without the vendor’s interfaces — the strict reading would retroactively disqualify the edge vendor ADR-0001 already admitted (sub-decision).
- Reconciliation sequencing. → Per-component clean plans, then one baseline drill. A no-op
terraform planis itself a per-component drift check, needing no new machinery and decomposing the work into separately reviewable changes; a single TR-06 drill whose TR-07 canary goes green then stamps the first last-known-good SHA. Acceptance on inspection was rejected for poisoning the TR-05 baseline at the moment it is established. The recorded limit is that clean plans reconcile to what is, so each revealed difference must be judged rather than merely zeroed (reconciliation note). - ADR-0001’s “peer surface alongside
cloud/”. → Honored at the module layer; bindings follow the seam. “Same repository” resolves per layer rather than per environment. The property ADR-0001 protected — that the home lab is not a hand-managed second-class environment — is preserved intact (reconciliation note).
1.2.4 - [0004] Rebuild Orchestrator & Phase Model
Parent capability: Self-Hosted Application Platform Addresses requirements: TR-02, TR-03, TR-04, TR-06, TR-07
Context and Problem Statement
TR-02 requires a single operator-invocable entry point that drives the rebuild from a fresh pull of the definitions, sequences the phases automatically, and can complete inside 60 minutes. It permits manual checkpoints between phases and forbids manual driving of each step. TR-03 fixes the first phase’s scope: both environments plus the connectivity between them, with single-environment standup not a supported outcome. TR-04 requires every phase to expose a deterministic, definitions-driven teardown callable at every checkpoint, so that “delete everything and start over” is always viable. TR-06 requires the same entry point to run against scratch infrastructure, with drill and live differing only in the underlying target.
None of the four name a tool, an execution location, or a checkpoint mechanism. This ADR decides all three, plus the phase list and its ordering.
What exists today
There is no orchestrator. ADR-0003 records the substrate as per-component Terragrunt root modules in the private Zaba505/homelab, each applied by its own GitHub Actions workflow_dispatch. Nothing sequences them. The ordering between components exists only in the operator’s memory — which is the same failure class ADR-0003 closed for the module reference, one level up: a rebuild today would be the operator dispatching workflows in a remembered order, with nothing recording what that order is or whether it was ever validated end to end.
The public repository contributes reusable modules under cloud/* and nothing that drives them. Confirmed by inspection: there is no Makefile, no Taskfile, no root terragrunt.hcl, and no orchestration workflow — .github/workflows/ carries only docs, CodeQL, and Terraform linting.
What the siblings already fixed
This ADR inherits rather than re-opens:
- ADR-0003 hands over phase 0 as decided — a definitions-driven state-backend bootstrap, parameterized by target, counted inside the TR-02 budget, and torn down last. Its Open Questions section states this ADR “still owns how the phases are sequenced and checkpointed.”
- ADR-0001 subdivides phase 1: the edge and public-cloud anchor stand up first, and the operations tunnel is a distinct checkpoint with its own teardown, so a failed tunnel is retried against an intact cloud anchor rather than costing a full foundations teardown. It also leaves home-lab tooling deliberately undecided, noting Terraform is “a weak fit for the bare-metal and OS-level state the home lab actually carries.”
- The standup UX fixes the operator’s experience: automation provisions, pauses at each phase boundary, prints a summary, and waits for the operator to validate against provider UIs and signal
continue. On any failure the operator tears down everything and restarts from the top; partial state is never carried forward.
What is actually undecided
The phase list is therefore largely determined. What is not:
- What holds the phase model — the ordering, the teardown inverse, and the target parameterization.
- Where it executes. ADR-0001 leaves home-lab tooling open, so the orchestrator must sequence heterogeneous work: cloud API calls on one side, bare-metal provisioning on the other. These have incompatible execution requirements, and that turns out to constrain the decision more than the phase list does.
- How the operator’s
continueis realized, given that the platform admits exactly one principal (TR-14).
Decision Drivers
- TR-02 — one entry point, automated, 60-minute-capable. An orchestrator that requires the operator to invoke each phase by hand puts the sequence back in their memory and fails the property, even if each phase is itself automated.
- TR-03 — phase 1 spans both environments plus the link. The orchestrator must drive work whose execution requirements differ fundamentally between the two sides, not just work that runs in two places.
- TR-04 — deterministic per-phase teardown at every checkpoint. “Deterministic” is the operative word: a teardown that is a hand-maintained mirror of the apply path drifts from it silently, and the drift is only discovered when teardown is needed most.
- TR-06 — drill and live differ only in target. This is a statement about parameterization, so target selection must be a single input threaded through every phase rather than a set of independently-set values.
- TR-01 — inherited from ADR-0003. The orchestrator and the phase model are themselves platform state, so they are definitions, and so is the toolchain they invoke. A rebuild whose result depends on which Terragrunt version happened to be on the runner is not reproducible from definitions.
- TR-18 — configuration control, portable export, and credential rotation without vendor cooperation. An orchestrator expressed entirely in one forge’s YAML is exportable in the trivial sense and portable in no useful sense: the phase model would have to be rewritten to move.
- TR-54 — the 2-hour weekly operator budget. A bespoke orchestrator is a thing that can rot, and its maintenance is charged here.
- TR-14 — exactly one principal. Any checkpoint mechanism requiring a second party is unsatisfiable by construction, exactly as ADR-0003 found for pull-request approvals.
- ADR-0003 constraint — phase 0 exists, is target-parameterized, is inside the budget, and tears down last. The teardown order is not flat.
- ADR-0001 constraint — the tunnel is its own checkpoint; home-lab tooling is undecided and must not be pre-empted here beyond what sequencing requires.
- Capability tiebreaker — tenant adoption beats reproducibility beats vendor independence beats minimizing operator effort. Reproducibility is again the operative term: it argues for the phase model being testable and the toolchain being pinned, and it outranks the operator-effort cost of building that.
Considered Options
Option A — GitHub Actions caller workflow holds the phase model
One rebuild.yml in the private repository, workflow_dispatch with a target input, one job per phase, needs:-chained. Each job invokes the relevant tool directly. Checkpoints are environment protection rules with the operator as required reviewer.
- Satisfies TR-02 cleanly on the entry-point property: one dispatch drives everything.
- Satisfies TR-06 — the
targetinput threads down through the jobs. - The checkpoint mechanism as specified is unavailable. GitHub’s documentation is explicit that on Free, Pro, and Team plans, required reviewers, wait timers, and custom deployment protection rules are only available for public repositories.
Zaba505/homelabis private, so environment-gated approval requires GitHub Enterprise. This is a plan-tier blocker, not a configuration detail. (Self-approval itself is not the obstacle — “Prevent self-review” defaults off, so a solo operator could approve their own job if the gate were available at all.) - Weak on TR-04: teardown becomes a second workflow that mirrors the apply path in YAML. Nothing enforces that the mirror stays accurate, and it cannot be tested.
- Weak on TR-18: the phase model is the forge’s YAML. Moving forges means rewriting the decision, not porting it.
- Weak on TR-01: tool versions come from whatever the runner image or a setup action provides, so the toolchain is ambient rather than defined.
Option B — One workflow_dispatch per phase
rebuild-phase.yml taking phase and target. The operator dispatches once per phase; the checkpoint is the decision to dispatch the next one.
- Best possible TR-54 position and the only option needing no new machinery at all. No plan upgrade, no polling, no third-party dependency.
- Fails TR-02’s central property. The sequence lives in the operator’s head — precisely the defect ADR-0003 closed one level down for the module reference. A “single top-level entry point” that must be invoked six times in a remembered order is not one entry point; it is the status quo with better labels.
- Weak on TR-04 for the same reason as Option A, plus the ordering of teardown is also unrecorded.
- Recorded so the rejection is auditable, since it is the cheapest option and its cheapness is real.
Option C — A purpose-built orchestrator in Go in the public repository; the forge is a thin invoker
The phase model, ordering, teardown inverse, and target parameterization live in code in Zaba505/infra. CI supplies the target and hosts the checkpoints; it holds no sequencing logic.
- Strongest TR-04 position available: teardown is a first-class inverse of the phase model rather than a parallel artifact, and it is unit-testable. This is the only option where “deterministic” is a property that can be asserted in a test rather than hoped for.
- Strongest TR-06: target selection is one typed parameter threaded through every phase by construction.
- Strongest TR-18: the phase model survives a forge change intact, and it runs identically on a workstation and in CI — which also makes it debuggable without burning a CI run.
- Honors ADR-0003’s seam without amendment: orchestration logic is reusable and parameterized, so it belongs in the public repository alongside
cloud/*; the target definitions that bind it are specific and bound, so they stay private. - Cost against TR-54: this is the most work of any option, and a bespoke orchestrator is a maintenance obligation with no natural forcing function to keep it current.
- Does not by itself supply a checkpoint mechanism — it still needs one from the layer above.
Option D — A dedicated workflow engine (Argo Workflows, Temporal)
- Real DAG execution, retries, and pause/resume as first-class primitives — nominally the best fit for TR-02 and TR-04.
- Circular and therefore disqualified. The engine needs compute to run on, and that compute is platform state this very flow is rebuilding. Phases 0 and 1 would depend on something phase 2 provisions. There is no ordering that makes this work.
- Enormous TR-54 cost for a single-operator platform even if the circularity were solved.
Option E — Ansible as the single orchestrator across both environments
- Genuinely spans TR-03’s both-sides scope in one tool, has a native
pausefor checkpoints, and post-iPXE the home lab needs OS-level configuration anyway — so it would fill ADR-0001’s undecided home-lab tooling at the same time. - Weak on TR-04 relative to graph-derived teardown: Ansible teardown is hand-written, so it carries Option A’s mirror-drift problem in a different language.
- Pre-empts a decision ADR-0001 deliberately deferred. Choosing the home-lab configuration tool as a side effect of choosing the orchestrator is exactly the coupling ADR-0001 declined to make, and it would be made here without the component-design context ADR-0001 said was better placed to weigh it.
- Retains the execution-location problem unchanged — Ansible still needs a control node.
Decision Outcome
Chosen option: Option C — a purpose-built orchestrator in Go in the public repository, realized as a Dagger module, with the forge as a thin invoker.
It is the only option under which TR-04’s determinism is testable rather than aspirational, and the only one that does not bet the phase model on a single forge (TR-18). Options A and B both put sequencing in YAML the operator maintains by hand, and Option B additionally fails TR-02’s entry-point property outright by returning the sequence to the operator’s memory. Option D is circular. Option E buys one-tool coverage of TR-03 by pre-empting ADR-0001’s deferred home-lab tooling decision, which is not this ADR’s to make.
Option A’s environment-protection blocker is worth stating plainly, because it would otherwise look like the obvious design: it is unavailable at this repository’s plan tier, and discovering that during implementation rather than here would have cost a rewrite of the checkpoint layer.
Why Dagger specifically
Dagger is chosen over hand-rolled Go orchestration for three reasons that map onto cited TRs, not on general merit:
- It makes the toolchain a definition (TR-01). Terragrunt, OpenTofu, and every other tool a phase invokes are pinned container images rather than whatever the runner image happens to carry. Without this, a rebuild’s result depends on ambient runner state, and “reproducible from definitions” is false in a way no amount of definition discipline elsewhere repairs.
- It makes the orchestrator genuinely portable (TR-18). The same module runs on a workstation and in CI, which is what turns Option C’s portability claim from an assertion into a property. It also means a forge migration moves an invocation, not a phase model.
- The operator has prior production experience with it, which materially lowers the TR-54 maintenance cost that is Option C’s main liability.
Dagger’s costs are accepted with their consequences recorded below: it is pre-1.0 (v0.21.x, no API-stability guarantee, with a breaking Modules v2 redesign shipped in v0.21), and its engine must run privileged, making the host the security boundary.
The phase model
flowchart TD
P0["Phase 0 — state backend<br/>ADR-0003 bootstrap, adopts own state"]
P1a["Phase 1a — cloud anchor + edge<br/>+ iPXE boot endpoint"]
P1b["Phase 1b — operations tunnel<br/>ADR-0001 distinct checkpoint"]
P1c["Phase 1c — home-lab base<br/>bare metal via iPXE"]
P2["Phase 2 — core services<br/>compute, storage, identity"]
P3["Phase 3 — cross-cutting<br/>backup, observability"]
P4["Phase 4 — canary tenant<br/>TR-07 binding readiness signal"]
P0 --> P1a --> P1b --> P1c --> P2 --> P3 --> P4
P4 --> Ready((Ready to<br/>host tenants))
P1c -.->|"TR-03: phase 1 incomplete<br/>until 1a+1b+1c all pass"| P1aEach boundary is an operator checkpoint. Teardown unwinds in reverse, with phase 0 last.
Sub-decision: the tunnel precedes home-lab bare metal, reordering ADR-0001’s phase 1
ADR-0001 fixed that the cloud anchor stands up first and that the tunnel is a distinct checkpoint. It did not fix the tunnel’s order relative to home-lab bare metal. This ADR fixes it: the tunnel comes first.
The reason is a circularity that only appears once execution location is considered. Bare-metal provisioning must be triggered — a machine that is powered off will never boot, whatever the boot infrastructure looks like. Triggering it means reaching the home lab’s LAN, whether by BMC power-on or otherwise. If home-lab bare metal preceded the tunnel, the orchestrator would need a path to the LAN that the rebuild has not yet built.
Ordering the tunnel first discharges this, because the tunnel’s home-lab endpoint does not depend on any home-lab host the rebuild provisions. ADR-0001 records that endpoint as configured out-of-band today (its residual risk 3), which means it lives on always-on network equipment rather than on a rebuilt machine. That property — inherited, not introduced here — is what makes the ordering work.
This does not weaken TR-03. Phase 1 remains incomplete until all three checkpoints pass, and single-environment standup remains unsupported; what changes is only the order in which the three are attempted, and the ADR-0001 benefit of retrying a failed tunnel against an intact cloud anchor is preserved exactly.
Sub-decision: iPXE chainloads from a stable cloud-hosted endpoint
The home lab’s boot artifacts — the iPXE script, kernel, initrd, and OS image — are served over HTTPS from the public-cloud anchor, provisioned as part of phase 1a. The only home-lab-local configuration is the DHCP directive pointing at the chainload URL.
The alternative considered was an always-on LAN appliance hosting boot artifacts and a self-hosted runner. It was rejected because it converts a large amount of platform state into a hand-built device that the rebuild flow depends on but does not build — so a disaster-recovery rebuild would begin with an untracked manual step, and the reproducibility claim would quietly exclude it.
Two consequences follow and are decided here rather than left to implementation:
- The chainload URL must be a stable DNS name, not an address or hostname that phase 1a mints fresh. If the URL changed per rebuild, the router’s DHCP configuration would need hand-editing during every rebuild and drill — reintroducing the manual step this sub-decision exists to avoid, at the worst possible moment. The name is stable; what it resolves to is provisioned by phase 1a.
- Boot artifacts are integrity-critical but not confidential. They are served over HTTPS from the cloud anchor; transport authentication is the control. They carry no secret material, so their public readability is not a finding.
The residual is recorded honestly: the router’s DHCP configuration remains out-of-band GUI state, which is drift under TR-01 in exactly the class of ADR-0001’s residual risk 3. This sub-decision shrinks the home lab’s untracked surface to approximately its minimum — one DHCP directive — but does not eliminate it, and it inherits rather than creates the obligation to express network-equipment configuration as definitions.
Sub-decision: the executor moves from hosted to cloud-anchor runner after phase 1a
Phases 0 and 1a run on GitHub-hosted runners: they touch only cloud APIs over outbound HTTPS, and nothing in them needs LAN reachability.
From phase 1b onward, execution moves to a self-hosted runner on the public-cloud anchor, provisioned by phase 1a. This runner reaches the home lab over the operations tunnel once phase 1b establishes it. Self-hosted runners require only outbound HTTPS to register and poll, so this needs no inbound exposure of the cloud anchor.
The point worth stating is what this arrangement avoids: no runner is required inside the home lab, and none is a precondition of the rebuild. The executor for home-lab-touching work is platform state that the rebuild itself provisions in phase 1a, so it is definitions-driven like everything else. An always-on LAN runner would have been a hand-built precondition, with the same objection as the appliance rejected above.
The accepted cost is a runner handoff in the middle of the rebuild, which is real complexity: phase 1a must provision and register a runner that later phases then target, and a failure to register is a phase-1a failure with a somewhat unintuitive symptom. It is recorded rather than minimized.
Sub-decision: checkpoints are separate invocations gated on the engagement thread
Dagger has no primitive for waiting on human input — Terminal() exists for debugging, not for pipeline-time approval — so the checkpoint cannot live inside the orchestrator. Combined with Option A’s finding that environment protection rules are unavailable on a private repository below Enterprise, the mechanism is fixed as follows:
Each phase is a separate invocation of the orchestrator from a needs:-chained job in a single rebuild.yml. Between phases, a gate step posts the phase summary to a GitHub issue and polls until the operator replies continue.
This satisfies TR-02 on both limbs: one workflow_dispatch drives the whole rebuild, so the entry point is genuinely single, while the operator validates at boundaries without driving steps. The phase model stays in Go regardless — what CI holds is the invocation sequence and the gate, not the ordering logic.
Routing the gate through an issue thread is not merely a workaround for the plan tier. It lands checkpoint acknowledgments on the TR-19/TR-20 engagement channel that is already the platform’s record of operator action, so the rebuild leaves an auditable trail of what was validated and when — which environment approvals would have recorded only in run metadata.
The costs are accepted: a job parked on a poll consumes runner minutes, unlike an environment approval, and hosted jobs cap at six hours. Neither binds a rebuild targeting 60 minutes, but an abandoned rebuild left parked overnight is waste rather than merely idle.
Sub-decision: every side-effecting operation carries a per-run cache-buster
This is the highest-risk item in the design and it is decided as a hard correctness rule, not an optimization.
Dagger caches function calls on module source plus argument values, and caches withExec operations at the BuildKit layer. Critically, the documented cache="never" control on a function does not disable layer caching for the execs inside it. The failure mode this produces is documented in the wild — dagger#7090 and dagger#9607 both record deploy and destroy operations returning cached success while the infrastructure no longer existed.
For this capability that is not a performance bug. A teardown that reports success without executing defeats TR-04 precisely when it matters — the operator believes “delete everything and start over” succeeded, restarts onto state that was never removed, and carries partial state forward, which is the one outcome the standup UX rules out by name. A cached apply is the same defect pointed the other way, and it would make the TR-07 canary’s green signal untrustworthy.
The rule: every exec that mutates infrastructure — apply, destroy, boot-trigger, canary deploy — takes a per-run unique value (the rebuild’s run identifier) as an environment variable used only for cache invalidation. Read-only operations may cache freely.
Enforcing this by convention alone was considered and rejected: the failure is silent, and a missed cache-buster surfaces as a successful-looking rebuild. The obligation recorded below is therefore that side-effecting operations be constructed through a single helper that injects the buster, so that omitting it requires bypassing the helper rather than merely forgetting a parameter.
Sub-decision: the teardown contract
Every phase exposes a teardown callable at every checkpoint, per TR-04. Four properties are fixed:
- Teardown unwinds in reverse phase order, and phase 0 is last — inherited from ADR-0003, whose state bucket holds the state every other phase’s teardown depends on. Torn down out of order it strands exactly the resources the definitions can no longer address.
- Terraform-backed phases derive teardown from the dependency graph rather than a hand-written inverse. Terragrunt reverses the graph on destroy, so dependents are removed before dependencies. This is what makes “deterministic” mean something stronger than “we wrote a second script.”
- Phase membership and teardown order are verifiable before execution. Terragrunt can emit the exact unit set and destroy-order a given scope selects, so a phase’s teardown can be dry-run and asserted in a test rather than trusted. The orchestrator uses this as the definition of a phase’s Terraform-backed membership.
- Teardown must be scoped so it cannot exceed its phase.
run --all destroydestroys external dependencies of the selected units by default, which would let one phase’s teardown reach outside itself. Phase scoping must exclude external dependencies explicitly, and this is a correctness requirement of the teardown contract rather than a tuning flag.
Two limits are recorded rather than solved. Terragrunt offers no resume-from-failure and no transactional rollback, so a teardown interrupted partway leaves a partially-destroyed graph that the next attempt must be able to tolerate — re-running teardown must be safe. And phase 1c’s teardown is not resource deletion: tearing down bare metal means powering off and wiping, which is a different determinism story than a cloud API destroy and is the weakest link in the per-phase contract.
Sub-decision: drill-vs-live is a single named target
TR-06 requires drill and live to differ only in the underlying target, so target selection is one parameter naming a target definition, not a set of independently-set values. A target definition binds the GCP project, the state bucket, the DNS zone, the boot endpoint, and the home-lab target in one tracked object. Every phase receives it; no phase reads any of those values from anywhere else.
The single-parameter form is the decision, and its rationale is the same one ADR-0003 used for collapsing the module reference from a tuple to a single pin: independently-set values can be combined into a state that was never validated, and here that state would be a drill pointed at live infrastructure.
Target definitions are specific and bound, so per ADR-0003’s seam they live in the private repository; the orchestrator that consumes them is reusable and parameterized, so it lives in the public one.
Sub-decision: a drill’s home-lab target is virtual hosts on the home-lab LAN
The single-target form above is straightforwardly satisfiable for every cloud phase — a drill target names a different project, bucket, and zone. It is not straightforwardly satisfiable for phase 1c: a drill cannot reprovision the live home lab’s bare metal without destroying the thing it is drilling against, and there is no second set of hardware.
The decision: a drill target’s home-lab target names virtual machines on existing home-lab hypervisor capacity, where a live target names the physical hosts. Phase 1c then runs unchanged — same chainload, same boot artifacts, same OS image, same post-boot definitions — against virtual NICs and virtual disks. TR-06’s “differ only in the underlying target” stays literally true, because the virtualization is a property of the target rather than a branch in the flow.
Why home-lab capacity rather than the cloud. Virtualizing the home-lab side into the cloud anchor was the cheaper-looking variant and is rejected: it would make phase 1b degenerate, with the tunnel terminating on both ends inside one environment. A drill would then no longer span two environments, and TR-03’s both-sides-plus-the-link coverage would be silently narrowed in exactly the mode that exists to verify it. Keeping the virtual hosts on the home-lab LAN preserves the tunnel as a real crossing, which is the property a drill most needs to hold.
BR-50’s “without touching live platform state” is satisfied on the distinction that matters: a drill VM consumes live capacity but produces no live platform state.
The two alternatives are recorded with their rejections. Spare hardware as a standing precondition gives the highest fidelity, and is rejected on the same objection that killed the LAN boot appliance above — it makes the rebuild flow depend on a hand-maintained device the rebuild does not itself build. Cloud-only drills cost nothing, and are rejected because they make drill and live differ in scope as well as target, which lets the reproducibility KPI certify a rebuild that skips a phase.
Two consequences follow and are decided here rather than left to implementation:
- Drill hosts sit on an isolated segment with their own DHCP chainload directive. The stable name decided above points at the live target’s boot endpoint. A drill VM booting on the live LAN’s DHCP scope would therefore chainload live artifacts — a drill that quietly rebuilds from the live target is worse than no drill at all, because it returns a green signal for something it never tested. The drill segment’s directive points at the drill target’s boot endpoint. This grows the out-of-band router surface from one directive to two; it does not change its class.
- What a drill does not prove is named rather than implied. Firmware, BMC power-on, disk controller and layout, and NIC driver behaviour — the failure modes specific to metal — go unexercised. A drill certifies the phase model, the sequencing, the definitions, and the teardown; it does not certify that this hardware boots. That is the honest bound on what the reproducibility KPI demonstrates, and it belongs next to the KPI rather than being discovered after a disaster-recovery event.
Sub-decision: phase 4 exposes an idempotent canary teardown, and a canary that will not tear down is a phase-4 failure
TR-07 has the canary deployed, exercised, and torn down within phase 4, so its teardown is partly intrinsic to the success path. That is not sufficient for TR-04, and the reason is specific: the checkpoint at which phase 4’s teardown is actually reached is the one where the canary failed — the standup UX’s “canary tenant fails to come up” case — and there the intrinsic teardown never ran. A phase whose teardown assumes the success path has no teardown for the case it exists to serve.
Phase 4 therefore exposes the standard TR-04 entry point as an idempotent removal of the canary’s runtime footprint wherever it landed — compute, persistent storage, identity registration, backup enrolment, and observability series, matching the surfaces the standup UX has the canary exercise. Idempotence is the operative property, because the teardown cannot know which of three states it faces: a canary that removed itself cleanly, one that removed itself partway, or one that never deployed. All three must be safe.
What it does not remove is the canary’s definitions. TR-07 maintains the canary alongside the platform definitions, so it is a permanent artifact; teardown removes what a rebuild instantiated, never the canary itself.
The second half of the decision is a claim about readiness: a canary that comes up green but does not tear down cleanly is a phase-4 failure. TR-07 names deployment, exercise, and teardown as one obligation, but the reason is not symmetry — the canary’s teardown is the platform’s tenant-offboarding path exercised for real. A canary that cannot be removed has demonstrated that the platform cannot offboard a tenant, which is a readiness defect exactly as much as one that cannot be onboarded, and the standup UX’s rule that readiness does not bend for a failed canary applies unchanged.
This is also where the cache-buster rule is load-bearing a second time. Canary deploy and canary teardown are both side-effecting and both go through the mandatory helper; a cached canary teardown would report a clean removal that never happened, leaving residue that the next rebuild’s canary then collides with.
Sub-decision: checkpoint wait time counts against the 60-minute budget
The TR-02 budget is measured as wall-clock from invocation to canary-green, including time parked at checkpoints.
Excluding operator validation time was considered and rejected on ADR-0003’s precedent, which refused to exclude phase 0 from the budget on the reasoning that the budget is a target rather than a gate — missing it files a follow-up issue and does not stop the platform going into service, so exclusion protects nothing while letting the KPI be measured against something other than the real rebuild. The same logic applies here, and more directly: the standup UX has the operator record how long the rebuild took, which is unambiguously the elapsed experience rather than the automation’s share of it.
The consequence is that the KPI partly measures operator validation speed, which is honest rather than unfortunate — a rebuild requiring 40 minutes of squinting at provider consoles genuinely is a slow rebuild, and the follow-up issue it generates is pointed at a real problem.
Consequences
- Good, because the phase model, its ordering, and its teardown inverse become testable code rather than a hand-maintained YAML mirror — the only option under which TR-04’s “deterministic” is a property that can be asserted rather than hoped for.
- Good, because the toolchain is pinned into container images, so a rebuild’s result no longer depends on ambient runner state. This closes a TR-01 gap that none of the YAML-held options address.
- Good, because the orchestrator runs identically on a workstation and in CI, which makes TR-18 portability real and lets the phase model be debugged without burning CI runs.
- Good, because target selection collapses to one parameter, so a drill cannot be assembled into a configuration that partly points at live infrastructure.
- Good, because ordering the tunnel ahead of home-lab bare metal discharges a circularity — that bare metal must be triggered over a path the rebuild has not yet built — without weakening TR-03 or losing ADR-0001’s independent-tunnel-retry benefit.
- Good, because no runner and no boot appliance is required inside the home lab; the executor for home-lab-touching phases is provisioned by phase 1a, so it is definitions-driven rather than a hand-built precondition.
- Good, because checkpoint acknowledgments land on the TR-19/TR-20 engagement thread, leaving an auditable record of what the operator validated and when — which environment approvals would have left only in run metadata.
- Good, because drills exercise phase 1c for real rather than skipping it — the home-lab target is virtualized rather than the flow being branched, so TR-06’s “differ only in target” stays literally true and the reproducibility KPI certifies the whole phase model rather than its cloud half.
- Good, because keeping the drill’s virtual hosts on the home-lab LAN keeps phase 1b’s tunnel a genuine cross-environment crossing during a drill, so TR-03’s coverage is not quietly narrowed in the mode that exists to verify it.
- Good, because phase 4’s teardown is idempotent across all three states the canary can be left in, so TR-04 holds at the checkpoint the operator actually reaches most often — the failed canary, where the intrinsic success-path teardown never ran.
- Good, because treating a canary that will not tear down as a phase-4 failure makes each rebuild exercise the tenant-offboarding path and not only the onboarding one.
- Bad, because Dagger’s caching can silently skip side-effecting operations, and the documented
cache="never"control does not cover execs. A cached teardown reports success without executing, which produces exactly the carried-forward partial state the standup UX rules out. This is mitigated by a mandatory per-run cache-buster, but the mitigation is the design’s most load-bearing convention and its failure mode is a successful-looking rebuild. - Bad, because Dagger is pre-1.0 with no API-stability guarantee, and shipped a breaking Modules v2 redesign in the current minor line. The orchestrator inherits an upgrade treadmill charged against TR-54, and version pinning is mandatory rather than prudent.
- Bad, because the Dagger engine must run privileged, so the host is the security boundary. This is acceptable on a dedicated runner and would not be on a shared one.
- Bad, because iPXE cannot run inside Dagger. There is no host-network mode, and DHCP/TFTP is broadcast traffic that a NAT’d container bridge does not reach. Phase 1c’s boot-trigger work runs host-side, outside the Dagger graph — a real seam in an otherwise uniform execution model, and the one place where “the orchestrator holds the phase model” is qualified.
- Bad, because the runner handoff mid-rebuild is genuine complexity: phase 1a provisions and registers the runner that later phases target, and a registration failure is a phase-1a failure with an unintuitive symptom.
- Bad, because the router’s DHCP configuration remains out-of-band GUI state — drift under TR-01, in ADR-0001’s residual-risk-3 class. This decision shrinks the home lab’s untracked surface to roughly one directive but does not reach zero.
- Bad, because a job parked on a checkpoint poll consumes runner minutes, where an environment approval would not, and hosted jobs cap at six hours. Neither binds a 60-minute rebuild; an abandoned one is waste.
- Bad, because Option C is the most work of any option considered, and a bespoke orchestrator has no natural forcing function keeping it current. The operator’s prior Dagger experience lowers this cost but does not remove it.
- Bad, because phase 1c’s teardown is not resource deletion. Powering off and wiping bare metal is a weaker determinism story than a cloud destroy, making it the weakest link in the per-phase teardown contract.
- Bad, because a virtualized drill target does not exercise metal. Firmware, BMC power-on, disk controller and layout, and NIC driver behaviour go unproven, so the reproducibility KPI certifies the phase model and the definitions rather than that this hardware boots. This is recorded as the honest bound on the KPI rather than mitigated, and it is the residual a spare-hardware precondition would have bought out.
- Bad, because a drill’s phase-1c teardown is VM deletion rather than power-off-and-wipe, so drills exercise a teardown path that is both stronger than and different from the live one. The weakest link in the teardown contract is precisely the link drills do not test.
- Bad, because the drill’s home-lab hosts require a second out-of-band DHCP directive on an isolated segment. Without it a drill chainloads the live target’s boot artifacts and returns a green signal for something it never tested. The untracked router surface grows from one directive to two — the same TR-01 class, twice.
- Bad, because drill hosts consume live home-lab capacity. No live platform state is touched, which is what BR-50 tests, but a drill and the live platform contend for the same hypervisor — so drill sizing is bounded by what the home lab can spare while still serving.
- Bad, because phase 4’s teardown must be written defensively rather than derived. Unlike the Terraform-backed phases, the canary’s footprint spans identity, backup, and observability enrolments whose removal has no dependency graph to reverse, so this is the one teardown that is a hand-written inverse — the mirror-drift problem this ADR rejected Option A over, readmitted at one phase and accepted because TR-07’s canary surface is small and fixed.
- Neutral but load-bearing: Terragrunt provides no resume-from-failure and no transactional rollback, so re-running a teardown must be safe against a partially-destroyed graph. This is a property the orchestrator must hold, not one it inherits.
- Requires: a Dagger module in
Zaba505/infraholding the phase model, per-phase apply and teardown functions, and the target parameter, with the Dagger version and every tool image pinned. - Requires: a single helper through which all side-effecting execs are constructed, injecting the per-run cache-buster, so omission requires bypassing the helper rather than forgetting a parameter.
- Requires:
rebuild.ymlinZaba505/homelab— oneworkflow_dispatchtaking the target,needs:-chained per-phase jobs invoking the module, and the issue-thread gate step between phases. - Requires: target definitions in
Zaba505/homelabbinding project, state bucket, DNS zone, boot endpoint, and home-lab target as one tracked object per target. - Requires: phase 1a extended to provision the iPXE boot-artifact endpoint behind a stable DNS name, and to provision and register the cloud-anchor self-hosted runner.
- Requires: phase scoping that excludes external dependencies on teardown, plus a test asserting each phase’s membership and destroy order against the dry-run output.
- Requires: a standup-UX update — phase 0 from ADR-0003, the 1a/1b/1c subdivision with the tunnel ahead of bare metal, and the issue-thread checkpoint replacing the implied local
continue. - Requires: empirical verification that outbound TCP from a Dagger container reaches an RFC1918 address over the tunnel, before phases 2 and 3 are built on the assumption. Official documentation does not guarantee it.
- Requires: drill target definitions to name virtual home-lab hosts on an isolated LAN segment, with that segment’s own DHCP chainload directive pointing at the drill target’s boot endpoint — not the live one.
- Requires: home-lab hypervisor capacity sufficient to stand up the drill’s virtual host set alongside live workloads, which is a capacity floor the home-lab definitions surface must account for rather than discover at drill time.
- Requires: phase 4 to expose an idempotent canary teardown covering compute, persistent storage, identity registration, backup enrolment, and observability, safe against a canary that removed itself, removed itself partway, or never deployed — and constructed through the cache-buster helper like every other side-effecting operation.
- Requires: the standup UX update already noted above to additionally record that a canary which will not tear down cleanly fails phase 4, and that drills exercise the home-lab phase virtually.
Realization
Zaba505/infra(public) — new Dagger module (Go, Modules v2) holding the phase model, per-phase apply/teardown functions, the target parameter type, and the cache-buster helper. Reusable and parameterized, so it sits on the public side of ADR-0003’s seam alongsidecloud/*. Unit tests assert teardown ordering and phase membership.Zaba505/homelab(private) — newrebuild.yml(singleworkflow_dispatch,needs:-chained phase jobs, issue-thread gate steps) and new target definitions binding each drill/live target as one object. Existing per-component workflows are superseded by phase invocations.cloud/dns/— the stable DNS name fronting the iPXE chainload URL, which must survive across rebuilds independently of what phase 1a provisions behind it.cloud/storage-bucket/— boot artifacts (iPXE script, kernel, initrd, OS image) served over HTTPS from the cloud anchor.cloud/compute-engine/+cloud/service-account/— the cloud-anchor self-hosted runner provisioned by phase 1a, plus its registration identity.- Phase-0 bootstrap root module (
Zaba505/homelab) — from ADR-0003; invoked here as phase 0 and torn down last. cloud/vpc-network/and the tunnel module — phase 1b, ADR-0001’s distinct checkpoint, reordered ahead of home-lab bare metal.- Home-lab module surface (
Zaba505/infra, peer tocloud/) — from ADR-0003’s reconciliation note; phase 1c’s definitions land here once ADR-0001’s deferred tooling decision is made. The boot-trigger work runs host-side, outside Dagger. Must address both a physical and a virtual host target, and declare the hypervisor capacity a drill requires. - Canary tenant definitions (
Zaba505/infra) — from TR-07, maintained alongside the platform definitions and never removed by teardown. Phase 4’s apply deploys and exercises it; phase 4’s teardown removes its runtime footprint idempotently across compute, storage, identity, backup, and observability. tech-design.md(composed later byplan-tech-design) will fold the phase model into the rebuild-flow narrative alongside the other accepted ADRs.
Open Questions
None remain. Both questions this ADR previously carried are resolved and folded into the sections above.
Resolved
How the home-lab side of a drill is exercised. → A drill target’s home-lab target names virtual machines on existing home-lab hypervisor capacity, so phase 1c runs unchanged against virtual NICs and disks and TR-06’s “differ only in target” stays literally true. Virtualizing into the cloud anchor was rejected for making phase 1b’s tunnel degenerate and narrowing TR-03 coverage in the mode that exists to verify it; spare hardware was rejected on the LAN-appliance objection; cloud-only drills were rejected for making drill and live differ in scope. Recorded limits: metal-specific failure modes go unproven, drill hosts need their own DHCP chainload directive, and drill teardown is VM deletion rather than the live power-off-and-wipe (sub-decision).
The canary’s teardown relationship to phase 4. → Phase 4 exposes the standard TR-04 entry point as an idempotent removal of the canary’s runtime footprint, and a canary that will not tear down cleanly fails phase 4. The intrinsic success-path teardown is insufficient because the checkpoint where teardown is reached is the failed canary, where it never ran; idempotence is required because teardown cannot know whether the canary removed itself, removed itself partway, or never deployed. The canary’s definitions are never removed. Failure is a readiness failure because the canary’s teardown is the tenant-offboarding path exercised for real (sub-decision).
What holds the phase model. → A Dagger module in Go in the public repository. The forge holds the invocation sequence and the checkpoint gate; it holds no ordering logic. Chosen for testable teardown (TR-04), pinned toolchain (TR-01), and forge portability (TR-18) (Decision Outcome).
Phase ordering. → 0 → 1a cloud anchor + edge → 1b tunnel → 1c home-lab bare metal → 2 core → 3 cross-cutting → 4 canary. The tunnel is reordered ahead of bare metal because bare metal must be triggered over a LAN path the rebuild would not otherwise have built yet. TR-03 is unweakened: phase 1 completes only when all three checkpoints pass (sub-decision).
Home-lab boot infrastructure. → iPXE chainloads from a stable cloud-hosted endpoint provisioned in phase 1a. An always-on LAN appliance was rejected for making the rebuild depend on a hand-built device it does not itself build. The residual is one out-of-band DHCP directive (sub-decision).
Execution location. → Hosted runners for phases 0–1a; a cloud-anchor self-hosted runner, provisioned by phase 1a, from 1b onward, reaching the home lab over the tunnel. No runner is required inside the home lab. The cost is a runner handoff mid-rebuild (sub-decision).
Checkpoint mechanism. → Separate per-phase invocations from one
workflow_dispatch, gated on an issue thread. Environment protection rules are unavailable on a private repository below Enterprise, and Dagger has no wait-for-human primitive. Routing the gate through the TR-19/TR-20 engagement channel makes checkpoint acknowledgments auditable (sub-decision).Cache correctness. → Every side-effecting exec carries a per-run cache-buster, injected through a single mandatory helper. Dagger’s
cache="never"does not cover execs, and cached-success deploys and destroys are documented in the wild. A cached teardown produces the carried-forward partial state the standup UX rules out by name (sub-decision).Teardown contract. → Reverse phase order with phase 0 last; graph-derived for Terraform-backed phases; membership and order verifiable by dry-run; scoped to exclude external dependencies. Recorded limits: no resume-from-failure, and bare-metal teardown is power-off-and-wipe rather than resource deletion (sub-decision).
Drill-vs-live parameterization. → One parameter naming a target definition that binds project, state bucket, DNS zone, boot endpoint, and home-lab target together. Independently-set values were rejected on ADR-0003’s tuple reasoning — here the never-validated combination would be a drill pointed at live (sub-decision).
What counts against the 60-minute budget. → Wall-clock including checkpoint waits. Excluding operator validation time was rejected on ADR-0003’s precedent that the budget is a target rather than a gate, so exclusion protects nothing while making the KPI measure something other than the real rebuild (sub-decision).
1.3 - Business Requirements
Living document. This is regenerated from the capability and UX docs on demand. Numbering is append-only — once a BR is assigned, it keeps that number forever, even if removed (mark removed ones explicitly). Technical requirements cite BR-NN, so renumbering would silently break provenance.
Review gate. Set
reviewed_at:in the frontmatter to today’s ISO date once you have read and edited this document. Thedefine-technical-requirementsskill will refuse to extract TRs untilreviewed_atis newer than the file’s last modification.
Parent capability: Self-Hosted Application Platform
How to read this
Each requirement is forced by the capability or a user experience — it states, in business or user-outcome terms, what the system must guarantee. Decisions about the technical translation (cadences, durability levels, protocols) belong in tech-requirements.md. Decisions about how (which database, which library, which provider) belong in adrs/. If something in this list reads like a technical constraint or a chosen solution rather than a business demand, flag it for review.
Requirements
BR-01: Provide a single default hosting target for the operator’s capabilities
Source: Capability §Purpose & Business Outcome
Requirement: The platform must be the default place where the operator’s capabilities run, so that “where does this run?” is a solved question rather than re-litigated per capability. Any capability the operator defines must be eligible to run here unless it is explicitly exempted.
Why this is a requirement, not a TR or decision: This is the first stated business outcome of the capability and the rationale for the capability existing at all. It does not name a technology — it sets the demand that the platform exist and absorb every capability by default.
BR-02: Platform must be reproducible from its definitions
Source: Capability §Purpose & Business Outcome · UX: Stand Up the Platform §Journey
Requirement: The platform itself must be rebuildable from its definitions, with no manual snowflake configuration, so that a total loss does not mean a permanent loss of the platform. Any state the platform depends on must be expressible as part of the definitions.
Why this is a requirement, not a TR or decision: Reproducibility is one of the capability’s named outcomes and is the operative test of “self-hosted.” It does not specify how (which IaC tool, which packaging form) — only the demand that the platform be rebuildable from authoritative inputs.
BR-03: Operator must retain end-to-end control over platform components
Source: Capability §Business Rules & Constraints
Requirement: Wherever the platform uses third-party components (vendor services, public-cloud offerings), the operator must retain control of configuration, data, and the ability to leave. Vendor lock-in that prevents departure is unacceptable.
Why this is a requirement, not a TR or decision: “Self-hosted” is defined in the capability as operator-controlled end-to-end, not as forbidding all vendors. The BR sets the demand (retain control + ability to leave), not the implementation choice.
BR-04: Platform-level investments must accrue to all tenants
Source: Capability §Purpose & Business Outcome
Requirement: Improvements made at the platform level (resiliency, observability, backup, security) must benefit every tenant capability rather than be re-solved per tenant.
Why this is a requirement, not a TR or decision: This is one of the four stated outcomes the capability promises. It demands a property of the platform’s offerings (shared, not per-tenant), without choosing how that sharing is achieved.
BR-05: Only the operator may administer the platform
Source: Capability §Business Rules & Constraints
Requirement: There must be no co-operators, no delegated administration, and no shared day-to-day administration of the platform. The operator is the sole administrator.
Why this is a requirement, not a TR or decision: The capability’s “Operator-only operation” rule states this in absolute terms. It is a forced constraint — every UX is shaped around the operator being the only one with administrative reach.
BR-06: End users of tenants must have no direct access to the platform
Source: Capability §Business Rules & Constraints · UX: Move Off the Platform After Eviction §Constraints Inherited · UX: Move Off the Platform After Eviction §Journey
Requirement: End users of tenant capabilities reach the tenant, not the platform. The platform must have no notion of “end users” of itself, no UI for them, and no communication channel to them — including during eviction. Capability owners, not the platform, are responsible for notifying their own end users of any tenant lifecycle change (such as an impending shutdown).
Why this is a requirement, not a TR or decision: The capability explicitly excludes direct end-user access; the eviction UX reinforces it (the platform never tells end users “this tenant has been retired”) and assigns the notification responsibility to the capability owner. The BR forbids a class of behavior, not a specific implementation.
BR-07: A designated successor must be able to take over operation if the primary operator becomes unavailable
Source: Capability §Business Rules & Constraints · UX: Stand Up the Platform §Constraints Inherited
Requirement: The platform must support a designated successor operator who holds sealed/escrowed credentials and a runbook sufficient to keep the platform running if the primary operator becomes unavailable. Successor credentials are not used for routine operation.
Why this is a requirement, not a TR or decision: Operator succession is one of the capability’s business rules and the standup UX assumes a successor can run the rebuild flow. The BR demands that takeover be possible; how the seal works is a downstream concern.
BR-08: Platform must produce on-demand exportable archives of tenant data while healthy
Source: Capability §Business Rules & Constraints · UX: Move Off the Platform After Eviction §Journey
Requirement: While the platform is up, every tenant’s users (via the capability owner) must be able to retrieve their content as a portable archive without operator involvement. Export availability is conditional only on the platform being healthy.
Why this is a requirement, not a TR or decision: This is half of the capability’s “Operator succession” rule (the other half is the successor) and the central mechanism of the eviction UX. It states what the user must be able to obtain, not how the export is implemented. Pairs with BR-09, which forbids gaps in export-tooling coverage.
BR-09: Export tooling must exist for every kind of data the platform hosts
Source: UX: Move Off the Platform After Eviction §Edge Cases
Requirement: Export tooling must be a core platform feature, available for every data shape the platform hosts. There must be no tenant whose data shape lacks an export path at the time of eviction.
Why this is a requirement, not a TR or decision: The eviction UX explicitly states this cannot-happen-by-design property and treats any gap as a platform bug. The BR forbids a class of failure rather than naming a tool.
BR-10: Exports must include platform-produced verification material
Source: UX: Move Off the Platform After Eviction §Journey
Requirement: Each export the platform produces must be accompanied by a checksum/hash and total size in bytes, so the capability owner can verify integrity. Semantic correctness validation remains the capability owner’s responsibility; the platform’s verification is the ceiling of what it can offer on the user’s behalf.
Why this is a requirement, not a TR or decision: The eviction UX makes this guarantee explicit — the platform produces the integrity envelope; the user judges semantic correctness. The BR demands the envelope, not a specific hash function.
BR-11: Tenant data must remain retrievable for 30 days after eviction in a read-only state
Source: UX: Move Off the Platform After Eviction §Journey
Requirement: From the eviction date forward, the platform must hold the tenant’s data in an export-only, read-only state for 30 days, during which the export tool must continue to work. After 30 days, the platform must stop offering any tenant-accessible copy of that data.
Why this is a requirement, not a TR or decision: The eviction UX defines the 30-day window as a hard tenant-facing guarantee. It is a business commitment to the departing capability owner, not a technical translation.
BR-12: Export-tooling defects must pause the post-eviction retention countdown
Source: UX: Move Off the Platform After Eviction §Edge Cases
Requirement: If a failure rooted in the platform’s export tooling or data hosting prevents a clean export, the operator must pause that tenant’s retention-window countdown until a clean export can be produced. Failures rooted in the capability owner’s own validation steps must not pause the countdown.
Why this is a requirement, not a TR or decision: This is the only carve-out in the eviction UX’s otherwise-hard 30-day rule, and it allocates accountability — the platform absorbs slippage caused by its own defects, never the user’s. That is a business commitment.
BR-13: Tenants must declare resource needs, packaging form, identity choice, and availability expectations up front
Source: Capability §Business Rules & Constraints · UX: Host a Capability §Constraints Inherited
Requirement: To be hosted, a tenant must arrive packaged in the form the platform accepts, with declared resource needs, an identity-service choice, and acceptance of the platform’s current availability characteristics. The declarations are made in the tech design and reviewed before approval.
Why this is a requirement, not a TR or decision: The capability’s “Tenants must accept the platform’s contract” rule and the host-a-capability UX both make this the price of admission. The BR demands the declaration; what shape “packaging” takes is a downstream decision.
BR-14: Tenant onboarding must require explicit operator authorization
Source: Capability §Triggers & Inputs · UX: Host a Capability §Journey
Requirement: No capability may begin running on the platform without the operator explicitly authorizing it. There must be no self-service onboarding path.
Why this is a requirement, not a TR or decision: The capability lists this as a precondition; the UX’s “approved” comment is its operationalization. It is a control demand, not a technical translation.
BR-15: All capability-owner ↔ platform engagement must occur through a single, recorded, asynchronous issue-thread workflow
Source: UX: Host a Capability §Journey · UX: Migrate Existing Data §Journey · UX: Operator-Initiated Tenant Update §Journey · UX: Platform-Contract-Change Rollout §Journey · UX: Move Off the Platform After Eviction §Entry Point
Requirement: Every operator/capability-owner exchange (onboarding, modification, migration, platform-driven update, contract change, eviction) must occur on a single recorded, asynchronous-by-default issue thread that both parties can read and append to. There must be no self-service portal and no other front door, and exchanges must not happen over ephemeral channels (chat, email-only, voice) where the trail is lost.
Why this is a requirement, not a TR or decision: The UXes demand the properties — single thread, recorded, asynchronous, no other front door — without naming a tracker. Choosing a specific tracker (e.g. GitHub Issues) is a downstream decision recorded in an ADR, not here.
BR-16: Issue types must distinguish review scopes legibly
Source: UX: Host a Capability §Journey · UX: Migrate Existing Data §Journey · UX: Operator-Initiated Tenant Update §Journey · UX: Platform-Contract-Change Rollout §Journey
Requirement: Distinct issue types must exist for the distinct conversations the platform has: onboarding a capability, modifying a hosted capability, migrating data, operator-initiated forced updates, platform contract changes, and eviction. The type itself signals the operator’s review scope and the capability owner’s expectations.
Why this is a requirement, not a TR or decision: Each UX names its issue type explicitly and explains why it is distinct. It is a coordination demand on the platform, not a tooling decision.
BR-17: The platform contract must be evergreen for already-hosted tenants
Source: UX: Host a Capability §Journey · UX: Platform-Contract-Change Rollout §Constraints Inherited
Requirement: A capability owner must not be required to re-accept the platform’s contract on each modify request. Changes to the platform’s contract are the platform’s responsibility to communicate ahead of time and migrate tenants through; the contract is never sprung on a tenant during a modify request.
Why this is a requirement, not a TR or decision: The host-a-capability UX states the evergreen property; the contract-change rollout UX is its operationalization. It is a promise to the user, not a technical translation.
BR-18: Capability owners must be able to update tenant needs after onboarding via a delta-only review
Source: UX: Host a Capability §Journey
Requirement: Once a tenant is live, its capability owner must be able to file a modify request that the operator reviews scoped to the delta only — not as a full re-evaluation of the tenant.
Why this is a requirement, not a TR or decision: The host-a-capability UX’s change-later loop is built around this property. It is a user-experience demand on the modify path, not a tooling choice.
BR-19: Platform must work with tenants on fall-behind cases rather than evict
Source: Capability §Business Rules & Constraints · UX: Operator-Initiated Tenant Update §Journey
Requirement: When a tenant’s components have fallen behind what the platform supports, the default operator response must be to bring the tenant current rather than evict. Eviction in fall-behind cases must occur only as a downstream consequence of a missed operative delivery date, never as the first response.
Why this is a requirement, not a TR or decision: The capability’s eviction rule carves out this behavior explicitly; the operator-initiated-tenant-update UX is the carve-out’s operationalization. It is a user-facing commitment, not a technical translation.
BR-20: Forced-update issues must carry the external reason and inherited deadline
Source: UX: Operator-Initiated Tenant Update §Journey
Requirement: When the operator opens a platform update required issue, it must name the external pressure forcing the change (vendor sunset, CVE, EOL) and the deadline inherited from that pressure. Each forcing event gets its own issue, even when the same tenant is hit by multiple events at once.
Why this is a requirement, not a TR or decision: The UX makes both properties explicit and motivates them — the capability owner needs to see why and by when. It is a transparency demand.
BR-21: Extensions to inherited deadlines must be bounded by the external pressure’s safe slack
Source: UX: Operator-Initiated Tenant Update §Journey
Requirement: When a capability owner cannot ship within an inherited deadline, any extension granted must be sized to the slack the external pressure actually allows — never invented by the operator independent of that pressure. If the pressure leaves no safe slack, extensions must be refused.
Why this is a requirement, not a TR or decision: The UX explicitly states this constraint. It is a control on operator discretion to prevent the platform from absorbing risk it cannot honestly carry.
BR-22: A missed operative delivery date must result in a separate, linked eviction issue
Source: UX: Operator-Initiated Tenant Update §Journey
Requirement: When a capability owner misses the operative date for a forced update (the original inherited deadline or an agreed extension), the operator must open a separate eviction issue linked back to the update issue, and close the update issue as superseded. Eviction must not be re-policed inside the update flow.
Why this is a requirement, not a TR or decision: The UX prescribes this exact split, with the rationale that update-flow scope and eviction-flow scope must remain distinct. It is a coordination demand on the platform.
BR-23: Operator-driven contract changes must be communicated ahead of time via a single umbrella issue
Source: UX: Platform-Contract-Change Rollout §Journey
Requirement: When the operator chooses to retire an offering, change a packaging form, or alter availability characteristics, the change must be announced via a single umbrella issue tagging every affected capability owner, containing what is changing, what it is changing to, the deadline, the reason, and the migration guideline (where applicable).
Why this is a requirement, not a TR or decision: The contract-change rollout UX names this shape explicitly and motivates the umbrella over per-tenant issues. It is the operationalization of the evergreen promise.
BR-24: Contract-change deadlines must give every affected tenant at least two status-update cycles
Source: UX: Platform-Contract-Change Rollout §Journey
Requirement: When the operator picks a contract-change deadline, it must allow every affected tenant at least two full status-update cycles before cutoff — one to acknowledge and start, one to finish or surface blockers with time still to respond.
Why this is a requirement, not a TR or decision: The UX states this minimum explicitly. It is a fairness commitment that bounds operator discretion.
BR-25: Contract-change deadlines must not be negotiable per-tenant
Source: UX: Platform-Contract-Change Rollout §Journey
Requirement: Capability owners must not be able to negotiate per-tenant slips of a contract-change deadline. The deadline applies uniformly; only a global extension (covering every affected tenant) is available, and only when the migration guideline itself proves insufficient.
Why this is a requirement, not a TR or decision: The UX makes this rule absolute. It enforces that the deadline remains a deadline rather than degrading into a per-tenant negotiation.
BR-26: Capability owners must explicitly acknowledge contract-change umbrella issues
Source: UX: Platform-Contract-Change Rollout §Journey
Requirement: Each tagged capability owner on a contract-change umbrella issue must explicitly acknowledge the change in-thread. Silence in a multi-tenant thread is treated as non-engagement and feeds the same laggard branch as failing to migrate.
Why this is a requirement, not a TR or decision: The UX states the acknowledgment requirement and its consequence. It is an engagement contract, not a technical mechanism.
BR-27: During contract-change rollout, old and new forms must run concurrently when a replacement exists
Source: UX: Platform-Contract-Change Rollout §Journey
Requirement: When a contract change replaces an old offering with a new one, the platform must serve both forms concurrently throughout the rollout window. Full offering removals (no replacement) are exempt — the change is all-or-nothing at the deadline.
Why this is a requirement, not a TR or decision: The UX prescribes the concurrent rollout window and names the carve-out. It is a user-facing commitment that gives tenants room to migrate at their own pace.
BR-28: Replacement offerings must be implemented and running before a contract-change umbrella issue is filed
Source: UX: Platform-Contract-Change Rollout §Entry Point
Requirement: Where a contract change replaces an old offering with a new one, the replacement must already be implemented and running on the platform alongside the old one before the umbrella issue is filed. Tenants must never be asked to migrate against an unbuilt replacement.
Why this is a requirement, not a TR or decision: The UX states this as a precondition of the journey. It is a quality-of-rollout commitment.
BR-29: Operator must post regular status updates throughout a contract-change rollout
Source: UX: Platform-Contract-Change Rollout §Journey
Requirement: During a contract-change rollout the operator must post status updates on a regular schedule (cadence sized to the timeline), with the current snapshot in the umbrella issue body and each scheduled update also as a comment. Each update must report how many tenants are still on the old form, how many have migrated, which modify issues are open, and how much time remains.
Why this is a requirement, not a TR or decision: The UX prescribes both the cadence shape and the metrics. It is a transparency demand on rollout coordination.
BR-30: At the contract-change deadline, the old form must be removed and laggards must transition to eviction
Source: UX: Platform-Contract-Change Rollout §Journey
Requirement: On the contract-change deadline, the old form must be removed regardless of remaining tenants on it. For each tenant that has not migrated, the operator must open a separate eviction issue (linked to the umbrella) and the umbrella must close. No tenant may be silently broken on a removed offering.
Why this is a requirement, not a TR or decision: The UX prescribes this exact closeout behavior. It is the inverse of the evergreen promise — once communicated and given time, the deadline is real.
BR-31: Eviction must be allowed when needs and capabilities fundamentally diverge
Source: Capability §Business Rules & Constraints · UX: Move Off the Platform After Eviction §Persona
Requirement: The platform must be able to decline continued hosting for a tenant whose requirements it cannot meet — specialized hardware, regulatory constraints, an availability target stronger than the platform offers. Eviction is initiated by the operator, not the capability owner.
Why this is a requirement, not a TR or decision: The capability defines this rule and the eviction UX operationalizes it. It is a control demand on what the platform is allowed to refuse.
BR-32: Eviction-date negotiation must occur upstream of the eviction journey
Source: UX: Move Off the Platform After Eviction §Entry Point
Requirement: By the time the eviction issue is filed, the eviction date must already be agreed and not subject to renegotiation inside the eviction journey. The 30-day post-eviction retention is the only post-date slack and is fixed.
Why this is a requirement, not a TR or decision: The UX states this as a hard wall. It is a coordination commitment that protects both parties from re-litigation.
BR-33: Eviction issues must contain the date, the reason, and a link to export tooling with documentation
Source: UX: Move Off the Platform After Eviction §Entry Point
Requirement: An eviction issue must carry exactly the eviction date, the reason for eviction, and a link to the export tool with documentation describing how to use it and the export shape. Nothing else is required of the issue.
Why this is a requirement, not a TR or decision: The UX names these contents and treats the issue as self-sufficient. It is a content commitment to the departing user.
BR-34: Tenant compute and network must be torn down on the eviction date
Source: UX: Move Off the Platform After Eviction §Journey
Requirement: On the eviction date, compute and network for the tenant must be torn down. Tenant data then enters the export-only, read-only state covered by BR-11 for the duration of the retention window — no further writes by anyone.
Why this is a requirement, not a TR or decision: The UX prescribes this teardown distinctly from the data-state guarantee. Compute/network teardown is what makes the dataset stable for export; the read-only data window itself is BR-11’s commitment, referenced here rather than restated.
BR-35: Capability owner — not the platform — must notify their own end users of eviction
Status: Removed on 2026-04-28 — absorbed into BR-06.
The platform-no-communication-with-end-users rule was already absolute in BR-06; the eviction-context clarifier and the capability-owner-notifies content are now folded into BR-06 directly. Number retained per the doc’s append-only rule so existing TR citations (if any) stay valid.
BR-36: Platform must offer a one-shot migration-process runner for capability-owner-supplied jobs
Source: UX: Migrate Existing Data §Goal · UX: Migrate Existing Data §Constraints Inherited
Requirement: The platform must provide a runner for one-time migration jobs that the capability owner writes and packages. The platform runs the process; it does not write, debug, or shepherd it.
Why this is a requirement, not a TR or decision: The migration UX is built around this offering and is explicit about the seam — platform runs, owner authors. It is a service commitment that bounds platform responsibility.
BR-37: Migration jobs must be packaged in the same form as any other tenant component
Source: UX: Migrate Existing Data §Constraints Inherited
Requirement: The BR-13 packaging requirement applies to migration jobs without exception — the contract must not relax for migration. A process that cannot be packaged in the form the platform accepts cannot be run by the platform.
Why this is a requirement, not a TR or decision: The UX states this no-carve-out constraint explicitly. BR-13 establishes the packaging form; this BR forbids relaxing it for the migration case, which is the only place a relaxation might plausibly be argued for.
BR-38: Migration jobs must declare their re-run contract and any temporary spikes up front
Source: UX: Migrate Existing Data §Journey
Requirement: A migration request must declare whether the process is safe to re-run against an already-populated destination (or requires a wiped destination), and any temporary migration-only spike beyond the tenant’s steady-state footprint. Approval of spikes is bounded by what the platform can accommodate.
Why this is a requirement, not a TR or decision: The UX names both declarations as part of the operator’s review scope. It is a content demand on what tenants must communicate, not a technical translation.
BR-39: Migration peak footprint must not exceed twice the destination tenant’s steady-state footprint
Source: UX: Migrate Existing Data §Journey
Requirement: The peak temporary footprint of a migration (steady-state plus declared spike) must be no more than 2× the destination tenant’s steady-state compute and storage. If either dimension exceeds that threshold, the request must be rejected as written; the capability owner is asked to split, reduce, or resize the tenant first.
Why this is a requirement, not a TR or decision: The UX states the 2× limit as a hard review rule. It bounds the burden one tenant’s migration may place on the platform.
BR-40: Concurrent migrations across tenants must be supported
Source: UX: Migrate Existing Data §Journey
Requirement: The platform must support multiple migrations running at once across different tenants without changing each tenant’s experience of their own journey. Tenants must not expect exclusive use of the migration runner.
Why this is a requirement, not a TR or decision: The UX specifies this property explicitly. It is a capacity commitment that prevents migrations from serializing.
BR-41: Recovery from migration failure must follow the capability owner’s plan, not a platform-prescribed model
Source: UX: Migrate Existing Data §Journey
Requirement: When a migration job fails or its output fails validation, the next step must be whatever plan the capability owner provides (wipe-and-retry, resume, accept partial, abandon). The platform must not auto-clean, auto-retry, or prescribe a recovery model.
Why this is a requirement, not a TR or decision: The UX places the recovery decision squarely with the data owner. It is an allocation-of-responsibility commitment.
BR-42: A migration job artifact must be torn down on completion
Source: UX: Migrate Existing Data §Journey
Requirement: Once a migration job is closed (successful or abandoned), the platform must tear down the job. The platform must not retain it; re-running later means filing a fresh migration issue.
Why this is a requirement, not a TR or decision: The UX states the one-shot lifespan and tear-down explicitly. It is a lifecycle commitment that prevents migrations from accumulating into unmanaged state.
BR-43: Platform must provide secret management for tenant-supplied credentials referenced by their components
Source: UX: Migrate Existing Data §Journey
Requirement: The platform must offer a secret-management surface that capability owners can populate independently of the operator, so their components and migration processes can reference credentials by name without leaking the secrets through engagement-thread comments.
Why this is a requirement, not a TR or decision: The migration UX assumes such an offering and operationalizes its use. It is a capability-level demand for handling credentials safely.
BR-44: Each tenant must be provided compute, persistent storage, network reachability, identity, backup/DR, and observability
Source: Capability §Outputs & Deliverables
Requirement: For each hosted tenant, the platform must provide compute (a place for the application to run), persistent storage durable to the platform’s defined standard, network reachability both internal and external, identity and authentication for the tenant’s end users, backup and disaster recovery for tenant data, and observability that lets the operator and capability owner tell whether the tenant is healthy.
Why this is a requirement, not a TR or decision: This is the capability’s stated direct outputs. It is the inventory of what every tenant must receive, named in business terms. The identity entry has a tenant-choice carve-out captured separately in BR-46 (BYO identity); this BR commits to availability of the inventory, not to the platform being the sole source of identity.
BR-45: Platform-provided identity service must support the “lost credentials cannot be recovered” property
Source: Capability §Business Rules & Constraints · UX: Host a Capability §Constraints Inherited
Requirement: Any identity option the platform offers to tenants must be capable of honoring a Signal-style “lost credentials cannot be recovered” property. An identity option that cannot honor this property is not eligible to be the platform-provided identity service.
Why this is a requirement, not a TR or decision: The capability rule names this property explicitly because at least one tenant requires it. It is a forced constraint on the identity offering, not a vendor selection.
BR-46: Tenants must be able to bring their own identity if they choose
Source: Capability §Outputs & Deliverables · Capability §Triggers & Inputs
Requirement: Tenants must have the option to bring their own identity service rather than use the platform-provided one. Their decision is recorded in their tech design, not at onboarding time.
Why this is a requirement, not a TR or decision: The capability lists BYO identity as a tenant choice and the host-a-capability UX confirms it is recorded upstream of onboarding. It is a flexibility commitment.
BR-47: Platform must be rebuildable to “ready to host tenants” within 1 hour
Source: Capability §Success Criteria & KPIs · UX: Stand Up the Platform §Goal
Requirement: Starting from no platform at all (with definitions repo and root-level access in hand), the platform must be rebuildable to a ready-to-host-tenants state within 1 hour. The KPI is a target — exceeding it does not block the platform from going into service, but it must be tracked as a follow-up.
Why this is a requirement, not a TR or decision: This is the stated Reproducibility KPI of the capability and the standup UX is the journey it is measured against. It is a business commitment to recovery speed.
BR-48: Rebuild readiness must be validated end-to-end by a purpose-built canary tenant
Source: UX: Stand Up the Platform §Journey
Requirement: Standup must conclude with the deployment, exercise, and teardown of a purpose-built canary tenant maintained alongside the platform definitions. “Ready to host tenants” must be demonstrated by hosting a tenant — not declared from infrastructure self-checks alone.
Why this is a requirement, not a TR or decision: The standup UX defines this as the binding readiness signal. It is a confidence demand that infrastructure-only checks would not satisfy.
BR-49: Each rebuild phase must support clean teardown of partial state
Source: UX: Stand Up the Platform §Edge Cases · UX: Stand Up the Platform §Constraints Inherited
Requirement: Every phase of the rebuild must be reversible — “delete everything provisioned so far and start over” must be a viable, reliable option at every checkpoint. Partial state must not be trusted across a phase failure.
Why this is a requirement, not a TR or decision: The standup UX prescribes this property as part of “phase fails → tear down everything and restart.” It is a reliability demand on the rebuild flow.
BR-50: A reproducibility drill must run after every significant platform change and at least quarterly
Source: UX: Stand Up the Platform §Entry Point · UX: Stand Up the Platform §Constraints Inherited
Requirement: The reproducibility KPI must be honestly evaluated by running a parallel rebuild drill on scratch infrastructure after every significant platform change (any change that would alter what is rebuilt, what must be validated, or what must be trusted) and at least quarterly while the live platform keeps serving.
Why this is a requirement, not a TR or decision: The standup UX names this cadence explicitly as the integrity check on the KPI. It is a discipline commitment, not a tool.
BR-51: Platform must enforce tracked changes and immutability so drift can be detected before rebuild
Source: UX: Stand Up the Platform §Entry Point · UX: Stand Up the Platform §Constraints Inherited
Requirement: Every platform UX that can introduce platform state must enforce tracked changes and immutability. The standup journey must perform a preflight drift check whenever prior platform state exists; the check must pass (no unexplained differences) before rebuild begins.
Why this is a requirement, not a TR or decision: The standup UX prescribes the preflight drift check and is explicit that drift must be prevented and detected outside the rebuild flow. It is an integrity commitment.
BR-52: Rebuild must span both public and private infrastructure as part of foundations
Source: Capability §Business Rules & Constraints · UX: Stand Up the Platform §Journey
Requirement: The platform may span public-cloud and home-lab infrastructure, and rebuild must establish the foundations — including connectivity between the two — as part of the standard standup flow. Cross-environment connectivity is foundational, not an afterthought.
Why this is a requirement, not a TR or decision: The capability allows the span; the standup UX makes Phase 1 explicitly cross-environment. It is a scope demand on what the rebuild must produce.
BR-53: Tenant-facing observability must include a platform-standard health bundle
Source: UX: Tenant-Facing Observability §Journey
Requirement: Each capability owner with a live tenant must receive, automatically, a tenant-scoped view of a platform-standard health bundle: availability, latency, error rate, resource saturation, and restart/deployment events. Capability owners must not have to instrument their own capability to see these signals.
Why this is a requirement, not a TR or decision: The observability UX defines the bundle and names automatic provisioning. It is a content commitment of the observability offering.
BR-54: Capability owners must be able to self-serve their own alert thresholds
Source: UX: Tenant-Facing Observability §Journey
Requirement: Within the observability offering, the capability owner must be able to tune the thresholds at which alerts fire to them, without operator involvement. The platform must not prescribe what counts as unhealthy enough to alert on.
Why this is a requirement, not a TR or decision: The observability UX names this as the one self-service surface and motivates it as a maintenance-budget pressure-relief. It is an authority demand — the user decides their own alerting.
BR-55: Platform must push alerts to capability owners when their thresholds are crossed
Source: UX: Tenant-Facing Observability §Journey
Requirement: When a tenant signal crosses a capability-owner-set threshold, the platform must send an alert to the capability owner that names which signal and which capability. The alert path is a best-effort nudge, not the source of truth.
Why this is a requirement, not a TR or decision: The observability UX names email as the current channel but the BR captures the demand (push alerts on threshold crossings, name the signal and capability). The channel is a downstream decision.
BR-56: Tenant view must indicate degraded alert delivery when known
Source: UX: Tenant-Facing Observability §Journey · UX: Tenant-Facing Observability §Edge Cases
Requirement: When the observability offering knows its alert delivery to a tenant is degraded, the tenant view must surface that fact, so silence from the alert path is not mistaken for evidence of health. The pull view must remain authoritative for current health.
Why this is a requirement, not a TR or decision: The UX states this property explicitly and motivates it as a trust commitment. It is a transparency demand.
BR-57: Tenant observability access must be scoped to the tenant; cross-tenant visibility is operator-only
Source: UX: Tenant-Facing Observability §Entry Point · UX: Tenant-Facing Observability §Constraints Inherited
Requirement: A capability owner authenticated to the observability offering must land directly in their own tenant’s view and stay confined there for the rest of the session. There must be no mode-switch that broadens scope; only the operator sees across tenants.
Why this is a requirement, not a TR or decision: The UX names this isolation property and the capability’s operator-only rule reinforces it. It is a confidentiality commitment.
BR-58: Tenant observability access must be provisioned automatically as part of onboarding
Source: UX: Host a Capability §Journey · UX: Tenant-Facing Observability §Entry Point
Requirement: A capability owner whose onboarding has closed must already have a working login to the observability offering and a wired alert-delivery address — without filing a separate request. Observability is part of being hosted.
Why this is a requirement, not a TR or decision: Both UXes assume this is true at the moment a tenant is live. It is an integration commitment between onboarding and observability.
BR-59: Routine operator maintenance must remain within 2 hours per week
Source: Capability §Success Criteria & KPIs
Requirement: The total routine operation of the platform — across all hosted tenants and platform-internal work — must take no more than 2 hours per week of the operator’s time. If maintenance regularly exceeds this, the platform must be simplified, not grown.
Why this is a requirement, not a TR or decision: This is the Operator maintenance budget KPI. It is a hard upper bound on what the platform may demand of its operator and is referenced in nearly every UX as a pressure constraint.
BR-60: A tenant whose accommodation would push routine maintenance sustainably above twice the maintenance budget must be evictable
Source: Capability §Business Rules & Constraints · UX: Operator-Initiated Tenant Update §Journey · UX: Migrate Existing Data §Constraints Inherited · UX: Platform-Contract-Change Rollout §Constraints Inherited
Requirement: When continuing to accommodate a tenant would push routine maintenance sustainably above 2× the maintenance budget, or break reproducibility (require manual snowflake configuration), the platform must be able to evict that tenant. Either condition alone must be sufficient grounds.
Why this is a requirement, not a TR or decision: The capability defines the eviction threshold and several UXes name it as the operative trigger. It is the control that prevents the maintenance budget from being eroded indefinitely.
BR-61: Tenant adoption must be measured against implemented capabilities, with explicit-loss capture
Source: Capability §Success Criteria & KPIs · UX: Host a Capability §Edge Cases
Requirement: Adoption is measured by counting only implemented capabilities (deployed and serving end users in production) — defined-only or designed-only capabilities are neutral. An implemented capability that runs elsewhere counts negatively, and a tenant lost because the operator went silent must be recorded explicitly on the issue rather than being silently dropped.
Why this is a requirement, not a TR or decision: The capability defines the KPI’s mechanic; the host-a-capability UX defines the explicit-loss recording. It is a measurement-discipline commitment.
BR-62: Operating cost must remain proportional to delivered convenience and resiliency
Source: Capability §Success Criteria & KPIs
Requirement: Total operating cost must remain within what the operator considers acceptable given the convenience and resiliency the platform delivers. There is no fixed dollar target; the test is whether the operator would still choose to run the platform knowing the bill.
Why this is a requirement, not a TR or decision: This is the Cost stays proportional to value KPI. It is a business commitment that bounds investment without prescribing a number.
BR-63: Buy-vs-build trade-offs must be judged on convenience, resiliency, and cost only
Source: Capability §Business Rules & Constraints
Requirement: When the platform decides between buying and building a component, the inputs must be convenience, resiliency, and cost. Operator skill development must not influence the trade-off; “I want to learn this” is not, on its own, a valid reason to choose build over buy.
Why this is a requirement, not a TR or decision: The capability rule explicitly forbids skill-development as an input. It is a control on decision-making, not a technical translation.
BR-64: When a tenant needs something the platform does not yet provide, the default response must be to evolve the platform
Source: Capability §Business Rules & Constraints · UX: Host a Capability §Journey · UX: Migrate Existing Data §Constraints Inherited
Requirement: When a tenant capability requires something the platform does not yet offer, the default response must be to update the platform to provide it — bounded by the reproducibility and maintenance KPIs. The platform must not push the requirement back onto the tenant as a first response, but is not obligated to grow without bound.
Why this is a requirement, not a TR or decision: The capability defines this rule and the host-a-capability UX’s “new offering needed” branch operationalizes it. It is the rule that keeps the platform tenant-aligned without making it infinitely extensible.
BR-65: All tenant data — including platform-held backups — must be permanently deleted at the end of the 30-day post-eviction retention window
Source: UX: Move Off the Platform After Eviction §Journey · UX: Move Off the Platform After Eviction §Success
Requirement: When the 30-day post-eviction retention window ends, the platform must permanently delete the tenant’s data across every tier it controls — the tenant-accessible export-only copy and any deeper backup-tier copies. No residual platform-held copy of an evicted tenant’s data may survive day 30 in any tier, and no operator-only access path may persist past that point.
Why this is a requirement, not a TR or decision: BR-11 commits only to the tenant-accessible-copy side; this BR closes the symmetric question for the platform’s own backup tier. It is a privacy commitment to the departing tenant — the eviction date plus 30 days is the last day any platform-controlled copy of their data exists, full stop. The BR forbids residual copies as a class; how backups are pruned (retention-policy mechanics, deletion verification) is a downstream concern.
Open Questions
- Volunteered-but-parked technical translations. None volunteered during this extraction. Placeholder so re-extractions have a home for things like specific cadences, durability levels, or protocols that surface during conversation.
1.4 - Technical Requirements
Living document. This is regenerated from
business-requirements.md(and the capability/UX docs) on demand. Numbering is append-only — once a TR is assigned, it keeps that number forever, even if removed (mark removed ones explicitly). ADRs cite TR-NN, so renumbering would silently break provenance.
Review gate. Set
reviewed_at:in the frontmatter to today’s ISO date once you have read and edited this document. Theplan-adrsskill will refuse to enumerate decisions untilreviewed_atis newer than the file’s last modification.
Parent capability: Self-Hosted Application Platform Business requirements: business-requirements.md
How to read this
Each TR is forced — by a BR (the primary case), by a prior shared ADR, or by a repo-wide constraint. It says what the technical solution must do, not how. Decisions about how (which database, which protocol, which library) belong in adrs/, not here. If something in this list reads like a chosen solution rather than a constraint, flag it for review. If something has no BR or inherited-constraint source, raise a missing BR back to extract-business-requirements.
Requirements
TR-01: Platform state must be entirely expressible as version-controlled definitions
Source: BR-02 · BR-51 · UX: Stand Up the Platform §Constraints Inherited
Requirement: Every piece of platform runtime state — each offering, every per-tenant binding, every shared piece of configuration the platform depends on — must be expressible in a tracked-changes definitions repository. Anything modifiable outside that repository is drift, and any UX that introduces platform state must route through the same recorded-change surface.
Why this is a TR, not a BR or decision: BR-02 demands reproducibility; BR-51 demands tracked changes and immutability so drift can be detected. The technical translation is that the definitions repository is the only authoritative surface for platform-modifying writes. Which repository, which tracked-changes mechanism, and which immutability discipline are downstream decisions.
TR-02: Platform must expose a single top-level rebuild entry point that runs end-to-end from definitions in ≤60 minutes
Source: BR-02 · BR-47 · UX: Stand Up the Platform §Journey
Requirement: A single operator-invocable entry point must drive the rebuild from a fresh pull of the definitions repository, sequence the foundations → core services → cross-cutting → canary phases automatically, and be capable of completing within 60 minutes of wall-clock time on the target infrastructure when run end-to-end. Manual checkpoints between phases are permitted; manual driving of each step is not.
Why this is a TR, not a BR or decision: BR-47 is the 1-hour rebuild target; BR-02 is the demand that rebuild is from definitions only. The TR is the operative property — one entry point, automated, time-bounded — without naming a specific automation tool, language, or orchestrator.
TR-03: Rebuild Phase 1 must establish foundations across both public-cloud and home-lab environments and the connectivity between them
Source: BR-52 · UX: Stand Up the Platform §Journey
Requirement: The first rebuild phase must provision the public-cloud-side and home-lab-side foundations and the cross-environment connectivity between them, before any later phase proceeds. Single-environment standup (public-only or home-lab-only) is not a supported rebuild outcome.
Why this is a TR, not a BR or decision: BR-52 asserts the platform may span both environments and that connectivity is part of foundations, not an afterthought. The TR forces foundations-phase scope to include both sides plus the link; choosing the specific cloud, home-lab hardware, or tunnel mechanism is downstream.
TR-04: Each rebuild phase must support a deterministic, definitions-driven teardown of all state it produced
Source: BR-49 · UX: Stand Up the Platform §Edge Cases
Requirement: Every rebuild phase must expose a deterministic, definitions-driven teardown that removes every resource the phase produced, callable at every checkpoint. “Delete everything provisioned so far and start over” must be a viable, reliable option at each phase boundary. Partial state must not be carried across a phase failure into the next phase.
Why this is a TR, not a BR or decision: BR-49 demands that partial state never be trusted across a phase failure. The TR is the operative property — every phase has a clean teardown — without prescribing a teardown mechanism.
TR-05: Rebuild flow must perform a preflight drift check that fails closed when prior platform state exists and unexplained differences remain
Source: BR-51 · UX: Stand Up the Platform §Entry Point
Requirement: Before any rebuild begins, the platform must compare current platform state against a last-known-good reference and refuse to proceed if unexplained differences remain. On a first-ever build the check is vacuously satisfied; in every other case the check must pass before later phases run.
Why this is a TR, not a BR or decision: BR-51 demands that drift be detected outside the rebuild flow rather than discovered partway through it. The TR makes the preflight check a property of the rebuild entry point. The mechanism by which “last-known-good reference” is captured and compared is a downstream decision.
TR-06: Rebuild flow must be runnable on parallel/scratch infrastructure without affecting the live platform
Source: BR-50 · UX: Stand Up the Platform §Entry Point
Requirement: The same definitions and the same rebuild entry point must be invocable against scratch infrastructure to support post-significant-change drills and at-least-quarterly drills, without touching live platform state. Drill mode and live mode must differ only in the underlying target.
Why this is a TR, not a BR or decision: BR-50 demands honest evaluation of the reproducibility KPI via parallel rebuilds. The TR forces drill-vs-live parity at the entry-point level; how target selection is parameterized is a downstream decision.
TR-07: Platform must include a purpose-built canary tenant maintained alongside the definitions, used as the rebuild’s binding readiness signal
Source: BR-48 · UX: Stand Up the Platform §Journey
Requirement: A canary tenant must be maintained alongside the platform definitions and must be deployed, exercised end-to-end against every platform-provided service, and torn down by the rebuild’s final phase. Readiness must not be declared on infrastructure self-checks alone; the canary’s pass/fail is the binding signal.
Why this is a TR, not a BR or decision: BR-48 names the canary as the readiness mechanism. The TR makes the canary a first-class artifact of the platform definitions. What the canary’s workload looks like, and which signals it must produce, are downstream decisions.
TR-08: Platform must accept tenant components — including migration jobs — only in a single pre-declared packaging form, with no carve-outs
Source: BR-13 · BR-37 · UX: Migrate Existing Data §Constraints Inherited
Requirement: Exactly one packaging form is admissible to the platform. Any tenant component, including migration job artifacts, must arrive in that form to be runnable; the migration path must not relax it. Components that cannot be packaged this way cannot run on the platform.
Why this is a TR, not a BR or decision: BR-13 commits the platform to a packaging form; BR-37 forbids relaxing it for migration. The TR forces single-form admission; the actual form (container image, OCI bundle, archive, etc.) is an ADR.
TR-09: Onboarding must require machine-readable declarations of resource needs, packaged artifact, identity choice, and availability acceptance before any provisioning is possible
Source: BR-13 · BR-46 · UX: Host a Capability §Constraints Inherited
Requirement: A tenant onboarding submission must, before provisioning is possible, include machine-readable declarations of (a) the tenant’s resource needs (compute, storage, network), (b) the packaged artifact in the platform’s accepted form, (c) the identity choice — platform-provided or BYO — recorded in the tech design, and (d) acceptance of the platform’s current availability characteristics. Approval binds the runtime to those declarations.
Why this is a TR, not a BR or decision: BR-13 names the four declarations as the price of admission; BR-46 makes the identity choice one of them. The TR makes the declarations a hard precondition of the provisioning gate; the schema and review surface are downstream decisions.
TR-10: Tenant runtime must not be provisioned without an explicit operator-issued authorization signal tied to the onboarding artifact
Source: BR-14 · UX: Host a Capability §Journey
Requirement: Provisioning of a new tenant runtime must be gated on a per-tenant authorization signal issued by the operator’s identity and bound to the specific onboarding submission. There must be no provisioning path that bypasses this gate, and there must be no self-service onboarding path.
Why this is a TR, not a BR or decision: BR-14 forbids self-onboarding and demands explicit authorization. The TR forces a control point on the provisioning surface; how the authorization signal is represented (issue comment, signed approval, etc.) is downstream.
TR-11: Onboarding flow must support a “new offering needed” hold and resume without requiring the capability owner to refile
Source: BR-64 · UX: Host a Capability §Journey
Requirement: When an onboarding tenant requires an offering the platform does not yet provide, the onboarding record must be holdable in a pending state and resumable from that point once the offering is added — without the capability owner refiling, restarting, or re-accepting the contract. The hold is bounded by the reproducibility (TR-02) and maintenance-budget (TR-54) limits.
Why this is a TR, not a BR or decision: BR-64 makes platform evolution the default response to a tenant need; the host-a-capability UX names the hold as the operationalization. The TR forces the hold-and-resume property on the onboarding flow without prescribing how the pending state is represented.
TR-12: BYO-identity declarations must produce a tenant runtime with no platform-side binding to the platform-provided identity offering
Source: BR-46 · UX: Host a Capability §Constraints Inherited
Requirement: A tenant whose declaration is “BYO identity” must be provisionable without the platform-provided identity offering being wired in for end-user authentication. The platform’s responsibility for that tenant’s identity is limited to network reachability to the chosen external identity service.
Why this is a TR, not a BR or decision: BR-46 commits the platform to BYO as a real option. The TR forces the provisioning flow to honor the choice without coupling tenants to the platform-provided service; which external services are reachable is downstream.
TR-13: Platform-provided identity offering must support a no-recovery credential property
Source: BR-45 · Capability §Business Rules
Requirement: The platform-provided identity offering must, per tenant electing it, support a configuration where no actor (operator, platform, third-party vendor) can recover a lost end-user credential — no reset email, no recovery code, no admin override. An identity option that cannot honor this configuration is not eligible to be the platform-provided service.
Why this is a TR, not a BR or decision: BR-45 forces the property because at least one tenant requires it. The TR is the operative constraint on the offering’s surface. Which identity service is selected to satisfy it is an ADR.
TR-14: All platform administrative interfaces must reject any principal other than the operator
Source: BR-05 · Capability §Business Rules
Requirement: Every platform-administrative surface — provisioning, deprovisioning, contract change, eviction issuance, secret rotation, drift reconciliation, etc. — must authenticate the caller as the operator’s identity (or, when invoked, the sealed successor’s). No delegated-administrator role, co-operator role, or shared admin credential exists.
Why this is a TR, not a BR or decision: BR-05 makes operator-only operation absolute. The TR closes the surface at the authentication layer rather than restating the rule. Which authentication mechanism is chosen is downstream.
TR-15: Platform offerings must expose no end-user-addressable surface
Source: BR-06 · UX: Move Off the Platform After Eviction §Constraints Inherited
Requirement: Platform offerings — observability, secret management, export tool, identity, migration runner, etc. — must expose no UI, API endpoint, or notification channel addressable by tenant end users. Authenticated principals on platform offerings are limited to operator and capability-owner roles; communication to end users about tenant lifecycle (including eviction) is the capability owner’s responsibility, not the platform’s.
Why this is a TR, not a BR or decision: BR-06 forbids any end-user surface on the platform. The TR translates this into the platform’s per-offering surface design. What the operator and capability-owner surfaces actually look like is downstream.
TR-16: Platform must hold a sealed/escrowed successor credential set sufficient to assume full operator authority
Source: BR-07 · Capability §Business Rules · UX: Stand Up the Platform §Persona
Requirement: A sealed credential set must exist that, when invoked by the designated successor, grants full operator authority — including running the rebuild flow and exercising every administrative interface covered by TR-14. The seal must be unsealable by the successor without participation from the primary operator. Routine operations must not exercise these credentials.
Why this is a TR, not a BR or decision: BR-07 forces successor capability and the seal-vs-routine distinction. The TR forces the credential-set property. The specific seal mechanism (password manager handoff, physical envelope, escrow service) is an ADR.
TR-17: Each tenant must receive provisioned compute, persistent storage, internal/external network reachability, identity (or BYO binding), backup/DR, and observability — implemented as shared platform offerings
Source: BR-04 · BR-44 · Capability §Outputs
Requirement: For every approved tenant, the platform must provision and operate, for the tenant’s lifetime, the full inventory: compute, persistent storage, internal and external network reachability, identity binding (platform-provided or per TR-12 BYO), backup with disaster recovery for tenant data, and observability. Each must be implemented as a shared platform offering consumed by every tenant — not duplicated per tenant.
Why this is a TR, not a BR or decision: BR-44 lists the inventory; BR-04 demands platform-level investments accrue to every tenant. The TR fixes the inventory and the shared-offering shape. Specific durability levels, network protocols, and backup retention windows are downstream.
TR-18: Third-party components admissible to the platform must allow control of configuration, data export, and credential revocation/rotation without vendor cooperation
Source: BR-03 · Capability §Business Rules
Requirement: Any third-party component the platform integrates must allow the operator to (a) read and modify configuration through the platform’s tracked-changes surface, (b) export platform-held data in a portable form, and (c) revoke or rotate platform-held credentials without vendor cooperation. Components that fail any of these are not admissible.
Why this is a TR, not a BR or decision: BR-03 forbids vendor lock-in that prevents departure. The TR turns “self-hosted” into a per-component admissibility test. Which vendors are chosen is downstream.
TR-19: All operator/capability-owner engagement must occur on a single durable, append-only, ordered thread per lifecycle event, accessible asynchronously
Source: BR-15 · UX: Host a Capability §Journey
Requirement: Every operator/capability-owner exchange (onboarding, modify, migration, forced update, contract change, eviction) must occur on exactly one durable engagement thread per event, append-only and ordered, accessible asynchronously to both parties, with the full history preserved. Ephemeral channels (chat, voice, email-only) are not acceptable as the channel of record.
Why this is a TR, not a BR or decision: BR-15 demands single-thread, recorded, asynchronous engagement. The TR fixes the channel properties without choosing a tracker.
TR-20: Engagement channel must distinguish onboarding, modify, migration, forced-update, contract-change, and eviction at the type level
Requirement: The engagement channel must support categorization that legibly separates onboarding, modification, data migration, operator-initiated forced update, platform-contract change, and eviction. Issue types must not collapse review scopes; an eviction triggered by a missed forced-update or contract-change deadline must always be a separate, linked issue from the issue that motivated it.
Why this is a TR, not a BR or decision: BR-16 demands distinct issue types per review scope; BR-22 forbids re-policing eviction inside the update flow. The TR is the typing constraint on the engagement surface; the type names and tracker semantics are downstream.
TR-21: Modify-request review must surface only the delta from the tenant’s currently-accepted declarations
Source: BR-17 · BR-18 · UX: Host a Capability §Journey
Requirement: The modify flow must support reviewing the proposed delta from the tenant’s currently-accepted declarations, without requiring the capability owner to re-accept the platform contract or the operator to re-evaluate the tenant’s full prior state.
Why this is a TR, not a BR or decision: BR-17 makes the contract evergreen; BR-18 makes modify review delta-only. The TR forces the property on the modify-review surface; how the delta is computed and displayed is downstream.
TR-22: Forced-update issues must record external pressure name and inherited deadline; one issue per forcing event per affected tenant
Source: BR-19 · BR-20 · UX: Operator-Initiated Tenant Update §Journey
Requirement: The forced-update issue type must require fields for (a) the external pressure forcing the change (vendor sunset, CVE, EOL) and (b) the deadline inherited from that pressure. When the same tenant is hit by multiple unrelated forcing events at once, each event must produce its own issue, even when remediation overlaps. Forced-update issues must remain open across multiple artifact handoffs and not progress toward eviction until the operative delivery date is missed.
Why this is a TR, not a BR or decision: BR-20 demands the two fields and the per-event split; BR-19 forbids early eviction. The TR fixes the issue-type schema and the lifecycle property; what tracker enforces the schema is downstream.
TR-23: Forced-update flow must record both inherited and any extended operative date, and the extension’s external-slack justification, both queryable by the eviction trigger
Source: BR-21 · UX: Operator-Initiated Tenant Update §Journey
Requirement: The forced-update issue must record the original inherited deadline, any negotiated extended operative date, and the external slack that justifies any extension. Both dates must be queryable as inputs to the eviction trigger. Extensions exceeding the named safe slack must be refused; if the external pressure leaves no safe slack, no extension is offered.
Why this is a TR, not a BR or decision: BR-21 bounds extensions by external slack. The TR makes the bounds machine-checkable. The shape of the slack record is downstream.
TR-24: Contract-change rollout must be initiated as a single multi-recipient umbrella issue carrying change, replacement, deadline, reason, and migration guideline
Source: BR-23 · UX: Platform-Contract-Change Rollout §Journey
Requirement: The umbrella-issue type must support a single artifact tagging every affected capability owner and carrying (a) what is changing, (b) what it is changing to (or that it is being removed), (c) the deadline, (d) the reason, and (e) the migration guideline where applicable. Per-tenant fanout for the rollout coordination itself is forbidden in this flow.
Why this is a TR, not a BR or decision: BR-23 names the umbrella shape and its contents. The TR fixes the issue-type schema; how multi-recipient tagging is implemented is downstream.
TR-25: Contract-change deadlines must be at least 2× the chosen status-update cadence after filing
Source: BR-24 · UX: Platform-Contract-Change Rollout §Journey
Requirement: The umbrella issue must record both the deadline and the operator-chosen status-update cadence. The interval between filing and the deadline must be no less than two full cadence cycles. Combinations of cadence and deadline that violate this must be rejected by the rollout flow.
Why this is a TR, not a BR or decision: BR-24 demands at least two status cycles before cutoff. The TR makes the relationship machine-checkable; the cadence values themselves are operator decisions per rollout.
TR-26: Contract-change deadline must be a single global value; per-tenant overrides are not supported
Source: BR-25 · UX: Platform-Contract-Change Rollout §Journey
Requirement: The umbrella issue type must store exactly one deadline applicable uniformly to all tagged tenants. There is no schema for per-tenant deadline overrides; only a global extension covering every tagged tenant may modify the deadline value.
Why this is a TR, not a BR or decision: BR-25 forbids per-tenant slips. The TR closes off the schema-level path to one. How global extensions are reflected in-thread is downstream.
TR-27: Umbrella issues must track per-tenant acknowledgment state; at deadline, the rollout flow must atomically remove the old form, close migrated modify issues, file linked eviction issues per laggard, and close the umbrella
Source: BR-26 · BR-30 · UX: Platform-Contract-Change Rollout §Journey
Requirement: Each tagged tenant must have an acknowledgment state on the umbrella issue. At the deadline, the rollout flow must atomically (a) remove the old offering from the platform regardless of remaining occupants, (b) close the migrated tenants’ modify issues in the normal way, (c) file a separate eviction issue per laggard tenant (including non-acknowledgers) linked to the umbrella, and (d) close the umbrella. No tenant may be silently broken on a removed offering.
Why this is a TR, not a BR or decision: BR-26 demands explicit acknowledgment; BR-30 prescribes the deadline closeout shape. The TR consolidates them into the rollout flow’s atomic close behavior; how atomicity is achieved is downstream.
TR-28: Replacement offering must already be a live, hosted offering on the platform before the umbrella issue may be filed
Source: BR-28 · UX: Platform-Contract-Change Rollout §Entry Point
Requirement: The contract-change flow must refuse to file an umbrella issue when (a) the change replaces an old offering with a new one and (b) the replacement is not yet a live, hosted offering on the platform. Full-removal contract changes (no replacement) are exempt from this gate.
Why this is a TR, not a BR or decision: BR-28 makes the precondition absolute. The TR makes it a filing gate; how “live” is verified is downstream.
TR-29: Platform must support running an old offering and its replacement concurrently for the rollout window when a replacement exists
Source: BR-27 · UX: Platform-Contract-Change Rollout §Journey
Requirement: For replacement-style contract changes, the platform must support tenants running on the old offering and the new offering simultaneously throughout the rollout window. The old offering must be removable on the deadline regardless of remaining occupants. Full-removal changes (no replacement) are exempt.
Why this is a TR, not a BR or decision: BR-27 commits to concurrent rollout windows. The TR forces the dual-form runtime property; whether concurrency is achieved by side-by-side instances, traffic splitting, or other means is downstream.
TR-30: Rollout view must produce, on the operator-chosen cadence, both a refreshed in-issue snapshot and a thread comment carrying tenants-on-old, tenants-migrated, open modifies, and time-remaining
Source: BR-29 · UX: Platform-Contract-Change Rollout §Journey
Requirement: On the operator-chosen cadence, the contract-change rollout flow must (a) refresh the umbrella issue body with the current snapshot — tenants on the old form, tenants migrated, open modify issues, time remaining — and (b) post a thread comment carrying the same metrics. Both surfaces must be present so a reader landing cold and a watcher tracking history see consistent rollout state.
Why this is a TR, not a BR or decision: BR-29 demands both the live snapshot in the issue body and a historical-comment trail. The TR fixes the dual-surface property and the metric set; how the snapshot is computed and rendered is downstream.
TR-31: Eviction issuance must be operator-only; eviction issues must be locked to their date at filing; required content is exactly date, reason, and link to export-tool documentation
Source: BR-31 · BR-32 · BR-33 · UX: Move Off the Platform After Eviction §Entry Point
Requirement: Filing an eviction issue must be restricted to the operator role; capability owners must have no path to initiate eviction. The eviction date must be set at filing and must not be mutable by either party afterward. Required content is exactly (a) eviction date, (b) reason, (c) link to the export-tool documentation; no other field is required.
Why this is a TR, not a BR or decision: BR-31, BR-32, and BR-33 together fix the issue’s authorship, immutability, and contents. The TR consolidates all three into a single constraint on the eviction-issue schema and authorization gate; the issue-type implementation is downstream.
TR-32: On the eviction date, tenant compute and network reachability must be deprovisioned and tenant data must transition to a read-only, export-only state
Source: BR-34 · UX: Move Off the Platform After Eviction §Journey
Requirement: On the eviction date, the platform must (a) deprovision the tenant’s compute and network reachability so end users can no longer reach it, and (b) transition tenant data to a read-only state in which no actor — including the capability owner, the operator, and tenant components — can write to it, while the export tool continues to function.
Why this is a TR, not a BR or decision: BR-34 prescribes the day-zero state transition. The TR is the operative property; whether read-only is enforced via permissions, snapshots, immutable storage, or another mechanism is downstream.
TR-33: Tenant data must remain readable via the export tool for 30 days post-eviction; on day 30, all tenant data must be permanently deleted across every storage tier the platform controls, with deletion verifiable to the operator
Source: BR-11 · BR-65 · UX: Move Off the Platform After Eviction §Journey
Requirement: For 30 days after the eviction date, tenant data must remain accessible only through the export tool. On day 30 the platform must permanently delete the tenant’s data across every storage tier it controls — the tenant-accessible export-only copy and any deeper backup-tier copies — with the deletion verifiable to the operator. No residual platform-controlled copy of an evicted tenant’s data may survive day 30 in any tier, and no operator-only access path may persist past that point.
Why this is a TR, not a BR or decision: BR-11 commits to the tenant-accessible side; BR-65 closes the symmetric question for backup-tier copies. The TR consolidates both into the cross-tier deletion property; how deletion is performed and verified per tier is downstream.
TR-34: Per-tenant 30-day retention countdown must be operator-pausable, with the pause distinguishing platform-side defects from capability-owner-side issues
Source: BR-12 · UX: Move Off the Platform After Eviction §Edge Cases
Requirement: The post-eviction 30-day retention countdown must be operator-pausable per tenant. The pause/resume action must record which class triggered it — platform-side defect (pauses the clock) or capability-owner-side issue (does not pause) — and the action must be auditable. Resumption must restart the remaining retention window, not the full 30 days.
Why this is a TR, not a BR or decision: BR-12 carves out exactly this pause behavior and allocates accountability. The TR makes the pause a controllable property of the retention-clock surface; the audit-record format is downstream.
TR-35: Platform must expose a per-tenant export tool callable without operator participation throughout the tenant’s hosted lifetime and the post-eviction retention window
Source: BR-08 · Capability §Business Rules · UX: Move Off the Platform After Eviction §Journey
Requirement: The platform must expose, per tenant, an export-tool invocation that produces a portable archive of that tenant’s data. The invocation must be available throughout the tenant’s hosted lifetime and across the 30-day post-eviction retention window without operator participation, and must be re-invocable on demand any number of times. The platform need not retain previously-generated archives between invocations.
Why this is a TR, not a BR or decision: BR-08 forces the on-demand, no-operator-needed export property. The TR fixes the invocation surface and re-invokability without prescribing an archive format.
TR-36: Each export must be accompanied by a platform-produced content checksum/hash and total byte count
Source: BR-10 · UX: Move Off the Platform After Eviction §Journey
Requirement: Every export artifact produced by the platform must be paired with (a) a content checksum or hash and (b) a total byte count, both produced by the platform at export time and delivered alongside the artifact. Semantic correctness validation remains the capability owner’s responsibility; the platform’s verification is bounded to the integrity envelope.
Why this is a TR, not a BR or decision: BR-10 forces the verification envelope. The TR fixes the two integrity outputs; which hash function is chosen is an ADR.
TR-37: Tenant admission must verify that an export-tool path covers every data shape the tenant will introduce
Source: BR-09 · UX: Move Off the Platform After Eviction §Edge Cases
Requirement: Tenant admission must verify that an export-tool path covers every data shape the tenant will introduce. A gap in export-tooling coverage must be treated as a platform defect that blocks admission until closed; admission may not proceed on the assumption that the gap can be filled later.
Why this is a TR, not a BR or decision: BR-09 forbids gaps in export coverage at eviction time. The TR moves the verification earlier — into admission — so eviction never discovers a gap. How coverage is enumerated and verified is downstream.
TR-38: Platform must offer a one-shot job runner distinct from long-running tenant components, with progress visible through standard observability
Source: BR-36 · UX: Migrate Existing Data §Journey
Requirement: The platform must offer a job-runner offering — distinct from the long-running tenant component runtime — that executes a packaged artifact end-to-end against a single tenant, exposes progress through the platform’s standard observability surfaces, and is bounded in lifetime by a single migration request. The platform runs the job; it does not write, debug, or shepherd it.
Why this is a TR, not a BR or decision: BR-36 commits to a one-shot job-runner offering. The TR fixes the offering’s separation from long-running runtime and the progress-visibility property; the runner’s implementation is downstream.
TR-39: Migration requests must declare re-run safety and any temporary-spike footprint up front
Source: BR-38 · UX: Migrate Existing Data §Journey
Requirement: A migration request must, at filing, declare (a) whether the migration process is safe to re-run against an already-populated destination tenant or requires a wiped destination, and (b) any temporary footprint spike beyond the destination tenant’s steady-state. Approval is bounded by available platform capacity for the declared spike.
Why this is a TR, not a BR or decision: BR-38 names both declarations as part of the operator’s review scope. The TR fixes the migration-issue schema; the schema’s representation is downstream.
TR-40: Migration approval must reject any request whose declared peak (steady-state plus spike) exceeds 2× the destination tenant’s steady-state in either compute or storage
Source: BR-39 · UX: Migrate Existing Data §Journey
Requirement: The migration review flow must reject — without negotiation — any request where steady-state plus declared spike exceeds 2× the destination tenant’s steady-state compute or storage. Resolution requires the capability owner to split the migration, reduce the spike, or resize the tenant first via the modify flow.
Why this is a TR, not a BR or decision: BR-39 makes the 2× cap a hard review rule. The TR makes the rule machine-checkable in the review surface; how steady-state is measured is downstream.
TR-41: Migration runner must support concurrent migrations across distinct tenants without serialization or per-tenant exclusivity
Source: BR-40 · UX: Migrate Existing Data §Journey
Requirement: The migration runner must support multiple migrations running concurrently across different tenants without serializing them or coupling their progress. Tenants must not depend on exclusive use of the runner for their own migration to proceed.
Why this is a TR, not a BR or decision: BR-40 commits to concurrent migrations. The TR forces the no-serialization property; how concurrency is implemented (shared infrastructure, per-tenant isolation, queueing) is downstream.
TR-42: Migration runner must not auto-clean, auto-retry, or auto-progress on job failure; subsequent action must be operator-driven against an explicit capability-owner plan
Source: BR-41 · UX: Migrate Existing Data §Journey
Requirement: On migration job failure or invalid output, the runner must hold the tenant data in whatever state the failed job left it. The next action — wipe-and-retry, resume, accept partial, abandon — must be operator-driven against a plan the capability owner provides on the issue. The platform must not auto-clean, auto-retry, or auto-prescribe a recovery model.
Why this is a TR, not a BR or decision: BR-41 places the recovery decision squarely with the data owner. The TR forbids the runner from acting on its own; the plan-record format is downstream.
TR-43: Migration runner must deprovision job artifacts on issue closure; re-running requires fresh job creation
Source: BR-42 · UX: Migrate Existing Data §Journey
Requirement: On migration issue closure (success or abandonment), the runner must remove all per-job artifacts. Subsequent re-runs must require a fresh migration issue and fresh approval; the platform must not retain a migration job past closure.
Why this is a TR, not a BR or decision: BR-42 fixes the one-shot lifespan. The TR makes the teardown an obligation of the closure flow; what counts as a “per-job artifact” is bounded by the runner’s design.
TR-44: Platform must offer a secret-management surface populated by capability owners and consumed by their components, with secret values not readable by any non-consuming party
Source: BR-43 · UX: Migrate Existing Data §Journey
Requirement: The platform must offer a secret-management surface where capability owners deposit credentials referenced by name from their tenant components and migration processes. Secret values must not appear in engagement-thread comments or in any operator-facing surface, and must not be readable by any party other than the platform components that consume them on the tenant’s behalf. Population must be doable by the capability owner without operator involvement.
Why this is a TR, not a BR or decision: BR-43 commits to this surface and motivates it as a leak-prevention measure for credentials. The TR makes the secrecy property and capability-owner population first-class. The implementation (key store, secret manager) is an ADR.
TR-45: Tenant-facing observability must expose, automatically per tenant, the platform-standard health bundle: availability, latency, error rate, resource saturation, and restart/deployment events
Source: BR-44 · BR-53 · UX: Tenant-Facing Observability §Journey
Requirement: For each live tenant, observability must surface — without capability-owner instrumentation — at minimum: availability, latency, error rate, resource saturation, and restart/deployment events. The bundle must be present from the moment the tenant goes live and must remain present for the tenant’s lifetime.
Why this is a TR, not a BR or decision: BR-53 defines the bundle’s content and the no-tenant-instrumentation property. The TR fixes both. Specific signal definitions, sample rates, and visualization shape are downstream.
TR-46: Capability owners must be able to mutate their own tenant’s alert thresholds without operator participation; cross-tenant threshold mutation is operator-only
Source: BR-54 · BR-57 · UX: Tenant-Facing Observability §Journey
Requirement: The observability offering must allow each capability owner to mutate alert thresholds for the signals on their own tenant, without operator involvement. Mutation of cross-tenant or platform-wide thresholds must be limited to the operator role.
Why this is a TR, not a BR or decision: BR-54 makes thresholds the one self-service surface for capability owners; BR-57 keeps cross-tenant scope operator-only. The TR forces the role-scoped mutation property on the threshold surface.
TR-47: On threshold crossings, observability must push an alert naming both the signal and the capability
Source: BR-55 · UX: Tenant-Facing Observability §Journey
Requirement: When a tenant signal crosses a capability-owner-set threshold, the observability offering must send an alert to the capability owner’s registered delivery address. The alert payload must name (a) which signal crossed and (b) which capability is affected. The alert path is best-effort; the pull view is authoritative.
Why this is a TR, not a BR or decision: BR-55 commits to threshold-driven push alerts and the content. The TR fixes the property and payload contents; the delivery channel is an ADR.
TR-48: Tenant view must surface alert-delivery health when degradation is detectable, while remaining the authoritative read of current health
Source: BR-56 · UX: Tenant-Facing Observability §Journey
Requirement: When the observability offering detects that alert delivery to a tenant is degraded, the tenant view must surface that fact so silence on the alert path is not interpreted as evidence of health. The pull view must remain authoritative for current health regardless of alert-path state.
Why this is a TR, not a BR or decision: BR-56 demands both the degradation indicator and the pull-authoritative property. The TR fixes them; how degradation is detected is downstream.
TR-49: Authentication to the observability offering must place a capability owner directly in their tenant scope with no mode-switch broadening it
Source: BR-57 · UX: Tenant-Facing Observability §Entry Point
Requirement: A non-operator authenticated session on the observability offering must land in the authenticated capability owner’s tenant scope and remain confined to it for the session’s lifetime. There must be no UI or API path that broadens scope to another tenant or to a cross-tenant view from a non-operator session.
Why this is a TR, not a BR or decision: BR-57 names the isolation property and the operator-only carve-out. The TR forces the session-scope property without choosing an authorization mechanism.
TR-50: Onboarding closure must produce a working observability login and a wired alert-delivery address as part of provisioning
Source: BR-58 · UX: Host a Capability §Journey · UX: Tenant-Facing Observability §Entry Point
Requirement: Closure of an onboarding issue must produce, as part of the same provisioning flow, a working observability login for the capability owner and a wired alert-delivery address. The capability owner must not need to file a separate request to obtain either.
Why this is a TR, not a BR or decision: BR-58 demands automatic provisioning of observability access at onboarding. The TR forces the bundle-with-onboarding property; the specific identity and delivery-channel mechanics are downstream.
TR-51: Onboarding-issue close-out must support a “lost — operator silence” outcome distinct from approved and declined, recorded in-thread
Source: BR-61 · UX: Host a Capability §Edge Cases
Requirement: The onboarding flow must support recording, at issue closure, three distinct terminal outcomes: approved (live tenant), declined (host elsewhere), and lost-to-operator-silence. Each must be a first-class queryable outcome, recorded in-thread, so adoption metrics can distinguish silent-loss from any other failure mode.
Why this is a TR, not a BR or decision: BR-61 prescribes the explicit-loss capture. The TR makes the outcome a first-class artifact rather than free-form text. The label and query surface are downstream.
TR-52: Eviction trigger must be invocable on the basis of either the maintenance-budget condition or the reproducibility-break condition; either alone is sufficient
Source: BR-60 · Capability §Business Rules
Requirement: The eviction-issuance flow must accept either grounds — projected routine maintenance sustainably exceeding 2× the maintenance-budget KPI, or any required snowflake configuration that cannot be expressed as definitions — as sufficient justification. The recorded grounds must be queryable for later review; both conditions need not be present together.
Why this is a TR, not a BR or decision: BR-60 makes either condition independently sufficient. The TR makes the grounds-record property explicit on the eviction surface; how the conditions are measured is downstream.
TR-53: Platform must produce queryable per-component cost data sufficient for the operator to judge cost-vs-value
Source: BR-62 · Capability §Success Criteria
Requirement: The platform must produce queryable per-component cost data on a regular cadence, sufficient for the operator to judge whether continuing operation is worth its bill. There is no fixed numeric target; the operator is the judge. Per-tenant attribution where attributable is desirable but not required by this TR.
Why this is a TR, not a BR or decision: BR-62 commits the platform to a cost-judgment surface without naming a target. The TR fixes the queryable-cost property; refresh cadence, granularity, and per-tenant attribution are downstream.
TR-54: Platform’s expected weekly operator-facing work, summed across the currently-hosted tenant set, must be designed to fit within a 2-hour weekly budget
Source: BR-59 · Capability §Success Criteria
Requirement: The set of routine operator-facing surfaces (alert handling, status updates, modify reviews, periodic checks) must be designed such that the platform’s expected weekly operator work, summed across the currently-hosted tenant set, fits within a 2-hour weekly budget. New surfaces whose costs are not predictable enough to bound this way must not be added without redesign or scope reduction.
Why this is a TR, not a BR or decision: BR-59 fixes the maintenance budget. The TR is the design-time obligation that follows: every operator-facing surface is bounded by its share of the 2-hour weekly envelope. How the budgeting is performed is downstream.
TR-55: Platform-managed resources must use the universal resource identifier standard
🗑️ removed on 2026-04-28 — sourced only to ADR-0006, which was deleted when the repo’s existing ADRs were cleared in preparation for the new capability development workflow. Number is reserved and will not be reused.
TR-56: Platform APIs must use the standard API error response format
🗑️ removed on 2026-04-28 — sourced only to ADR-0007, which was deleted when the repo’s existing ADRs were cleared in preparation for the new capability development workflow. Number is reserved and will not be reused.
Open Questions
Things volunteered as solutions during extraction (parked for the ADR stage), or constraints the capability/UX docs don’t yet make explicit.
- Buy-vs-build decision discipline (BR-63). BR-63 constrains the decision process, not the runtime system — it forbids citing operator-skill development as a justification in buy-vs-build trade-offs. It is not surfaced as a TR because there is no runtime obligation it forces; it is parked here for the ADR stage so per-component selection ADRs cite convenience/resiliency/cost evidence.
- Cost-data refresh cadence and granularity (TR-53). BR-62 demands cost-vs-value judgment but doesn’t quantify “regular,” “queryable,” or how granular per-component cost must be. Treat as ADR input alongside the observability-offering decisions.
- Status-update cadence sizing rules (TR-30, TR-25). BR-29 prescribes a regular cadence “sized to the timeline” and BR-24 imposes the ≥2-cycle deadline rule, but neither fixes a procedure for picking the cadence. Treat as ADR input or per-rollout operator guidance.
- Last-known-good reference for preflight drift check (TR-05). The standup UX names “the live platform or the last known-good environment” as the comparison surface but does not specify the form of “last known-good” (snapshot ID, signed manifest, etc.). Treat as ADR input for the drift-detection design.
- Topology adoption (TR-17, TR-03). The current repo pattern places an Internet-facing edge layer (with mutual-authentication and traffic-control duties) in front of a private home-lab environment connected to a public-cloud environment through a secure cross-environment tunnel. Whether the platform formally inherits this shape — or selects a different one for the cross-environment foundations — is an ADR decision; this TR doc deliberately does not assume the inherited pattern, and the specific vendors that currently realize each layer are out of scope here.
- Maintained checklist (UX: Stand Up the Platform). The standup UX references a “maintained checklist” used during phase validation. Its shape is unspecified; capture as ADR input alongside the rebuild-flow design.
- Public-cloud account vs. home-lab boundary in TR-18. Whether a public-cloud account itself counts as a “third-party component” for the BR-03 admissibility test (read/modify config, export data, revoke credentials without vendor cooperation) is ambiguous; the cloud is named in BR-52 as part of foundations and in the capability rules as allowed. Treat as ADR input when picking specific cloud-side components.
2 - Self-Hosted Personal Media Storage
One-line definition: Provide a self-operated place where the operator, family, and friends can store and share their personal media (photos, videos, files) under their own control instead of paying a third-party cloud provider.
Purpose & Business Outcome
What business outcome does this capability deliver? Why does it exist?
This capability exists so that a small, trusted circle of people (the operator, their family, and their friends) can keep their personal media without surrendering it to a commercial cloud provider. The outcomes it delivers, in order of importance:
- Privacy — Users’ content is not visible to, mined by, or monetized by any third party. The operator’s circle decides what happens to their data.
- Longevity — Content remains accessible and intact over long time horizons, not subject to a vendor’s pricing changes, product sunsets, or account terminations.
- Control — The operator decides how the system runs, who is on it, and how content is governed; users decide who sees their own content.
- Cost avoidance — Avoiding ongoing per-GB subscription fees to a commercial cloud storage provider is a real but secondary benefit.
When these outcomes conflict, the order above is the tiebreaker. Privacy beats convenience; longevity beats operator convenience; control beats cost.
Stakeholders
- Owner / Accountable party: The operator (Carson). Sole accountable party for the system existing, running, and continuing to run.
- Primary actors (initiators): Any authorized user — the operator, family members, or friends — uploading, viewing, sharing, or deleting their own content.
- Secondary actors / consumers: Other authorized users who have been explicitly granted access to a piece of content (individually or via a shared group/album).
- Affected parties (impacted but not directly involved): Subjects depicted in shared media (e.g. a family member who appears in a photo someone else uploaded) — they are affected by the privacy posture even if they are not the user who uploaded the content.
Triggers & Inputs
What initiates the capability, and what information must be available?
- Triggers:
- A user uploads a file (single, manual upload).
- A user’s device performs an automated backup (e.g. phone camera roll sync).
- A user performs a bulk import from an existing provider (e.g. a Google Photos takeout archive).
- A user views, downloads, or organizes their own content.
- A content owner shares content with another user or a shared group.
- A user deletes their own content.
- Required inputs:
- An authenticated identity for the acting user.
- The content itself (file bytes + whatever metadata the source provides, e.g. capture timestamps).
- For sharing actions: the identity of the recipient user or shared group.
- Preconditions:
- The user has been explicitly invited and provisioned by the operator (the user set is closed; no self-signup).
- The user holds their own credentials. Lost credentials cannot be recovered (see Business Rules).
Outputs & Deliverables
What does the capability produce? What changes in the world after it runs?
- Direct outputs:
- The user’s content is durably stored and retrievable by them.
- Shared content becomes retrievable by the explicitly named recipients.
- Content the user deleted is removed from their view immediately and purged from the system after the retention window (see Business Rules).
- Downstream effects / state changes:
- The user (and the people they share with) can rely on this system as their primary store and stop paying a commercial provider for the same content.
- The operator’s circle accumulates a long-lived, private archive of personal media that is not dependent on any external vendor.
Business Rules & Constraints
- Closed user set. Only the operator can add or remove users. There is no public sign-up. Users may be the operator, family members, or friends — chosen by the operator.
- Private by default. All content is private to its owner unless the owner explicitly shares it. Sharing may be one-to-one or via a shared group (e.g. a “family album”). No content is visible to other users — including the operator — without an explicit share.
- Lost credentials = lost data. If a user loses access to their credentials, their data is unrecoverable. The operator cannot reset access in a way that exposes the user’s content. This is a deliberate Signal-style trade-off in service of the privacy outcome.
- No storage quotas. Users are not subject to per-user storage limits. Capacity planning is the operator’s problem, not the users'.
- No illegal content. Users may not store content that is illegal in the operator’s jurisdiction. When a user lives in a different jurisdiction than the operator, the operator’s jurisdiction is the single governing law the system enforces — the operator runs the system and carries the legal exposure, so the operator’s local law sets the enforceable floor. Users remain independently responsible for complying with their own local laws, but the rule the operator acts on is the operator’s jurisdiction. The operator reserves the right to terminate a user’s access on credible evidence of a violation, even though the operator cannot inspect user content directly.
- No affected-party recourse process. There is no system-provided process for a non-user depicted in shared media (an affected party) to request its removal. This is a deliberate decision, not an omission: content is private within a closed, trusted circle, is shared only to explicitly named recipients, and the operator cannot inspect it or compel a content owner to remove it. Objections between people in the circle are expected to be resolved interpersonally, outside the system. The one exception is the No illegal content rule — where a depiction is itself illegal in the operator’s jurisdiction (e.g. non-consensual intimate imagery), the operator may act via the termination lever above on credible evidence.
- Off-site backup is allowed. Content may be replicated to an off-site location for durability, provided the off-site copy preserves the same privacy properties (no third party can read user content).
- 30-day retention after deletion / departure. When a user deletes content, or when a user leaves the system entirely, their data is retained for 30 days and then purged. The window exists for accident recovery; after it elapses, the data is gone.
- Operator succession. Longevity is preserved through two complementary mechanisms: (a) every user can pull a complete on-demand archive of their own content, without operator involvement, while the system is healthy — users are expected to pull these proactively (and may schedule periodic pulls), since on-demand export is only available when the system is up; and (b) a designated successor operator holds the credentials and runbook needed to keep the system running if the primary operator becomes unavailable. Successor credentials are sealed/escrowed (e.g. via a password-manager handoff or physical envelope) and not used for routine operation; takeover is a discrete event triggered by operator unavailability, not ongoing shared administration. Exports preserve user data even if no successor takes over; the successor preserves continuity of the system itself. If the system is down and no successor takes over, only previously-pulled exports survive — this is the accepted trade-off behind “lost credentials = lost data.” The successor’s access is to operate the system, not to read user content — the privacy properties (private by default, lost credentials = lost data) continue to hold.
Success Criteria & KPIs
- Number of active users. People in the operator’s circle who actually use the system as a place to put their content (not just provisioned accounts that sit idle). A user counts as active if they performed at least one of {upload, view, download, share} within the trailing 30 days; a provisioned user with no such activity in that window is dormant. Growth — or at minimum non-attrition — in the active count over time indicates the capability is meeting a real need versus their previous cloud provider.
- Zero data loss. No user ever loses content that they did not themselves delete. This is a hard binary: any incident of unintended data loss is a failure of the capability, regardless of cause.
Out of Scope
- General-purpose self-hosted application hosting (e.g. running a Minecraft server, hosting other apps for the operator’s circle). This is a separate capability and will be defined in its own document.
- Public sharing. Sharing content with anyone outside the operator’s invited user set is not supported.
- Collaborative editing of files. This capability stores and shares media; it does not provide document editing, comments, or other collaboration features.
- Monetization or commercial use. The capability serves a private circle; it is not a product offered to the public.
- Account/credential recovery. Explicitly excluded by the privacy posture above.
Open Questions
None at this time.
2.1 - User Experiences
This section documents the user experiences for the Self-Hosted Personal Media Storage capability — the end-to-end journeys taken by the actors named in the parent capability’s Stakeholders, in pursuit of the outcomes the capability promises.
2.1.1 - Bulk Import from a Prior Provider
One-line definition: A user moving off a commercial cloud provider brings their entire existing library — with its dates and organization intact — into their own storage in one go, so they can trust this as their new home and stop paying the old provider.
Parent capability: Self-Hosted Personal Media Storage
Persona
The actor is a recently-provisioned user — one of the parent capability’s Primary actors — who is mid-migration off a commercial cloud provider. They have already joined (see Join as an Invited User) and understand the deal; now they want to move their history, not just their new captures.
- Role: A user with a large, years-deep library sitting on a commercial provider (e.g. Google Photos, iCloud Photos, a Nextcloud instance they are leaving). They are not a data engineer; they think “my whole photo library,” not “an export archive with sidecar metadata.”
- Context they come from: They have decided — or are close to deciding — to stop paying the commercial provider. The one thing standing between them and cancelling is their existing content. This is explicitly a one-time migration, distinct from the routine ongoing capture in Upload Content.
- What they care about here: Getting everything over — with the capture dates and albums it already has — and being confident nothing was silently dropped before they pull the trigger on cancelling the old subscription. The fear underneath the whole journey is “what if I cancel and then discover a year of photos never made it?”
Goal
“I want my whole existing photo library — years of it, with the dates and albums it already has — brought over in one move, and I want to be confident nothing got dropped before I cancel my old provider.”
Entry Point
The user has decided to leave their prior provider and comes to this system to move their history across. Concretely, they arrive having obtained (or being about to obtain) an export/takeout archive from the prior provider — produced by that provider’s own export process, which this system does not control. Their state of mind is a mix of relief (finally leaving the vendor) and low-grade anxiety (this is their life’s photos; a botched move is not acceptable).
They may arrive before enabling routine device backup, or after — the two are independent. This journey is specifically about the back-catalog, not the go-forward stream.
Journey
- Obtain the takeout from the prior provider. The user runs the prior provider’s export and ends up with an archive of their library. What that archive contains and how faithful its metadata is are entirely the prior provider’s doing — the user (and this system) inherit whatever the vendor chose to include.
- Hand the archive to this system for import. The user gives the archive to the system and asks it to bring the library in. From here the user’s job is mostly to wait and, at the end, to verify. This is a fully self-service action: the user does not need the operator to run, stage, or babysit their import, and the operator is not a step in the routine path. That posture is deliberate — an operator-assisted import would turn every migration into bespoke operator labor, which the neighboring platform capability’s operator-maintenance-budget KPI explicitly guards against (migrations must not become one-off projects). If a user genuinely cannot complete an import self-service, that is treated as a product gap to close, not as routine operator work. The system accepts takeout from an explicitly named, deliberately small set of supported providers — the ones the operator’s closed circle actually leaves (e.g. Google Photos Takeout, an Apple/iCloud Photos export) — rather than promising to swallow any arbitrary archive. For each supported source it publishes an honest, per-source fidelity contract: what it can promise about capture dates and album/grouping survival from that specific provider’s export, because providers differ wildly (one splits metadata into JSON sidecars, another flattens album structure). A source the system does not support is refused plainly up front — “this provider isn’t supported” — rather than best-effort imported into a silently degraded library, because a generic guess would violate the honest-expectations principle this whole journey rests on. The supported-source list grows by operator decision as the circle’s needs demand, matching the capability’s closed, small user set.
- The import runs — potentially for a long time. Years of media is a lot. The user perceives that it is working and gets a legible sense of how far along it is and roughly how much remains, so a long-running import reads as “progressing,” not “hung.”
- Content lands, dated and organized. As the import completes, items appear in the user’s library carrying their capture dates and — as far as the takeout preserves it — their existing organization (albums/groupings). The user’s history slots into place as history, not as an undated dump. Items whose takeout carried no reliable capture date are not dropped, hidden, or stamped with an invented date; they land in an explicit, first-class “undated” state the user can later resolve — assigning the right date or album by hand — rather than sitting under a permanent, un-actionable flag. These items are fully stored, private, and durable from the moment they land; only their metadata is pending, so Zero data loss never waits on a date being figured out. The actual correction happens over in View and Organize Content; this journey’s job is to surface the undated set honestly and make it fixable, not to guess.
- Verify completeness. Before doing anything irreversible, the user gains genuine confidence that the import is complete — that what was in the old library is now in the new one. This is the pivotal step: the whole journey’s value hinges on the user being able to trust the import, not just be told it finished. Concretely, completeness is presented as a reconciliation against the archive the user actually handed over, layered so a non-technical user can trust the headline and still drill in when they want to: (a) a top-line count reconciliation — “of the N items in your archive, M imported” — as the at-a-glance trust anchor; (b) an explicit, itemized exceptions list of everything that could not be imported and why (unsupported item types, corrupt files, items with no usable metadata), so “couldn’t import” is never silent; and (c) where the source preserved albums, a per-album check so the user can confirm the groupings they care about came across intact rather than trusting a single aggregate number. The reconciliation is deliberately scoped to the archive the user handed over — not “everything you ever had” — and it says so plainly, so the user cannot mistake “imported everything in this takeout” for “imported everything that ever existed” when the vendor’s export was itself short (see Edge Cases).
- Cancel the prior provider. Only after that verification does the user cancel the commercial subscription. This ordering is the point at which the capability’s Longevity and cost-avoidance outcomes actually land — the user has stopped depending on the vendor and stopped paying them, without a gap.
Flow Diagram
flowchart TD
Decide([User decides to leave prior provider]) --> Takeout[Obtain takeout/export<br/>from prior provider]
Takeout --> Hand[Hand archive to this system<br/>for import]
Hand --> Run[Import runs — possibly long;<br/>progress is legible]
Run --> Interrupt{Interrupted or<br/>partially failed?}
Interrupt -->|Yes| Resume[Resume / re-run<br/>without mangling or duplicating]
Resume --> Run
Interrupt -->|No| Land[Content lands with capture dates<br/>& existing organization]
Land --> Verify{User confident<br/>import is complete?}
Verify -->|Not yet| Investigate[Investigate the gap<br/>before cancelling anything]
Investigate --> Verify
Verify -->|Yes| Cancel[Cancel prior provider]
Cancel --> Home((History now lives in private storage —<br/>Longevity & cost-avoidance realized))Success
A successful bulk import leaves the user with:
- Their whole history in their own private storage — dated and organized closely enough to what they had that it feels like the same library, moved, not a pile of loose files.
- Enough confidence to cancel the commercial provider. The emotional core of success is the moment they cancel without dread — because they verified completeness rather than hoping.
- The capability’s outcomes made real. This is the journey where Longevity (no longer depending on a vendor’s roadmap or pricing) and cost avoidance (stop paying per-GB) stop being promises and become facts for this user.
- A private result. Everything imported is visible only to them; the operator cannot see it (see Constraints Inherited — Private by default).
Edge Cases & Failure Modes
- Import is interrupted or partially fails. Experience-level handling: the import can be resumed or safely re-run without producing a mangled half-library or a doubled one. The user is never left unsure whether they now have some, all, or one-and-a-half copies of their history.
- The takeout has missing or degraded metadata. Some providers strip or flatten capture dates, or scatter album structure across sidecar files. Where the archive lacks a capture date, the system must set honest expectations — it surfaces that those items arrived without reliable dates rather than inventing plausible-but-wrong ones. The user would rather know “these 40 items have no date” than silently get 40 wrong dates. Those items land in the fixable “undated” state described in Journey step 4 — visible, resolvable by the user later, and never blocking the rest of the import.
- Overlap with content already added. If some items were already brought in via Upload Content or an earlier partial import, the user must not end up with a doubled library. Re-importing the same takeout is safe.
- A very large archive takes a long time. The user can walk away and come back; the import does not demand babysitting, and its progress remains legible across sessions so the user can check in.
- Gaining genuine confidence the import is complete — the crux. Premature trust is the dangerous failure here: the user believes the import is done, cancels the old provider, and only later discovers a silent gap that is now permanent (a Zero data loss failure). So the experience must give the user a real basis for the completeness judgment — a way to see that the count/scope of what came in matches what they had — not just a green “import finished” that hides omissions.
- The prior provider’s export itself is incomplete. If the vendor’s takeout was missing content to begin with, no import can conjure it. This is outside the system’s control, but the experience should not let the user mistake “imported everything in the archive” for “imported everything you ever had” if those differ — the verification should be about the archive the user actually handed over, and the user should understand that boundary.
Constraints Inherited from the Capability
This UX must respect the following items from the parent capability’s Business Rules and Success Criteria — named so future readers can trace the lineage:
- No storage quotas. A user’s entire multi-year library must fit — there is no per-user cap, and the user never has to prune to make room. Capacity planning is the operator’s concern. Without this rule, bulk import would be self-defeating.
- Private by default. Everything imported is private to the user. No third party — including the operator — can see the imported library. Import is not a sharing action.
- Off-site backup is allowed. Imported content benefits from the same durability guarantees as any other content, including any off-site replication, provided privacy is preserved. Invisible to the user, but part of why “it’s safe now” is true.
- Lost credentials = lost data. Once the user cancels the prior provider, this system holds the only live copy of their history, concentrated behind a single account whose credentials only they hold — so the same trade-off that protects that history (no operator backdoor) is now the one they carry: losing their credentials means losing the imported library, unrecoverably. That makes the unrecoverable-credentials trade-off (covered in depth by Join as an Invited User) especially consequential here, and raises the stakes on the habit of pulling proactive exports (see View and Organize Content); the import is the moment those habits stop being abstract.
- KPI — Zero data loss. This journey has an unusually sharp relationship to the KPI, because the user takes an irreversible external action (cancelling the prior provider) based on the import’s completeness. A silent drop that would be a minor annoyance elsewhere becomes permanent loss here. The verification step exists specifically to protect this KPI.
- Purpose priority — Longevity and cost avoidance. Bulk import is the concrete moment the capability delivers its Longevity outcome (independence from a vendor) and its secondary cost-avoidance outcome (ending the subscription). The journey is designed so the user only realizes these once the content is safely across — Longevity is never traded for the convenience of cancelling early.
- KPI — Number of active users. A successful import is a strong signal of a genuinely adopted user — someone who moved their real history in, not just an idle provisioned account. It also front-loads a large amount of content that the user will then view, organize, and share, feeding sustained activity.
Out of Scope
- Routine, ongoing capture. Everyday uploads and automatic device backup — the go-forward stream — are Upload Content. This doc is strictly the one-time back-catalog move.
- The prior provider’s own export process. How the user generates the takeout on Google Photos / iCloud / etc. is the vendor’s flow, not this system’s. This journey begins when the user has an archive in hand.
- Verifying, browsing, and re-organizing the landed library in depth. Confirming completeness is part of this journey, but living in the library afterward — searching, making new albums, pulling a full export — is View and Organize Content.
- Sharing the imported content. Making any of it visible to others is Share Content.
- Leaving this system and taking data back out. The reverse move — exporting from here on departure — is covered by View and Organize Content (on-demand export) and Delete Content and Leave (departure).
Open Questions
None remaining. The four questions this journey previously carried have been resolved and folded into the sections above:
- Accepted takeout formats & per-source metadata fidelity → the system accepts an explicitly named, deliberately small set of supported providers (the ones the operator’s closed circle actually leaves) and publishes an honest per-source fidelity contract for date/album survival, rather than promising to swallow any arbitrary archive; unsupported sources are refused plainly instead of silently degraded (Journey, step 2).
- How completeness verification is presented → a reconciliation against the archive the user handed over, layered as a top-line count reconciliation, an itemized exceptions list of what couldn’t be imported and why, and a per-album check where the source preserved albums — scoped explicitly to the handed-over archive, not “everything you ever had” (Journey, step 5).
- Self-service vs. operator-assisted → fully self-service; the operator is not a step in the routine import path, because an operator-assisted import would make every migration bespoke labor the platform capability’s operator-maintenance-budget KPI guards against. An import a user genuinely cannot complete alone is treated as a product gap to close, not routine operator work (Journey, step 2).
- Handling items without reliable metadata → they land in an explicit, first-class, fixable “undated” state the user can resolve later (never an invented date, never a permanent un-actionable flag), while remaining fully stored, private, and durable in the meantime (Journey, step 4 and Edge Cases).
2.1.2 - Delete Content and Leave
One-line definition: A user removes content they no longer want — with a 30-day safety net — and, if they ever choose, leaves the system entirely, exporting first because after the retention window their data is gone for good.
Parent capability: Self-Hosted Personal Media Storage
Persona
The actor is a user managing or removing their own footprint — a Primary actor from the parent capability’s Stakeholders — acting anywhere on the spectrum from deleting a single unwanted photo to departing the system entirely.
- Role: An everyday user exercising control over their own content: getting rid of what they don’t want, and — at the far end — deciding to leave altogether. Non-technical is the default assumption; they think “delete this,” “I’m done with this service,” not “issue a purge.”
- Context they come from: Small deletions come up constantly (a bad shot, a duplicate, something embarrassing). Departure is rare and weighty — a decision to leave the circle, or simply to stop using the system. Both are moments where the user wants to feel in control and safe from their own mistakes.
- What they care about here: Removing what they mean to remove, not losing something by accident, and — if they leave — taking their archive with them and understanding, honestly, that the system’s copy will be gone afterward.
Goal
“I want to get rid of stuff I don’t want — but not lose something by accident — and if I ever decide to leave, I want to take my archive with me and know my data is truly gone afterward.”
Entry Point
Two related entries, distinguished by scope and weight:
- Delete specific content. The user is in their library (View and Organize Content) and decides one or more items should go.
- Leave the system entirely. The user decides they are done — or the operator is removing them under the Closed user set rule (e.g. a credible No illegal content violation, or simply parting ways). This is a deliberate, infrequent, high-stakes act.
Journey
Branch A — Delete specific content
- Select and delete. The user picks the content and deletes it. It disappears from their main library view immediately, so the library reflects their intent right away.
- Safety net — a private “Recently Deleted” surface, 30 days. Deleted content moves into a “Recently Deleted” surface the user can open at any time within the window. It is self-service and visible to the user alone (never to the operator — Private by default), it shows each item with the time remaining before purge, and the user can restore any item back into their library themselves, with no operator involvement. This is the safety net made usable: recovery is a thing the user does, on their own, not a favor they request.
- Permanent purge. After 30 days, the content leaves “Recently Deleted” and is permanently purged — genuinely gone, with no operator recovery path. The window exists for accident recovery, not indefinite retention.
Branch B — Leave the system entirely
- Decide to leave (or be removed). The user initiates departure, or the operator removes them (only the operator can add or remove users). A voluntary departure follows the gated, reversible flow below; an operator termination for a credible No illegal content violation is handled differently — see step 5 and Edge Cases.
- Export first — gated, not merely offered. Before a voluntary departure completes, the user must pass through an explicit “export first” step that plainly states the stakes: the system’s copy ends after 30 days, and an export is their only lasting copy (see View and Organize Content). The experience gates departure behind an informed acknowledgment — the user cannot leave by accident or without being told, and export is offered right there. The system does not, however, force the download to actually happen (it cannot verify the user kept the file); the acknowledgment ensures the choice is informed and deliberate, and the 30-day window backstops a rushed exit. This step is available only while the system is healthy (Operator succession).
- Access ends. The user’s access to the system is closed.
- Retention — 30 days, reversible via the operator. Their data is retained for 30 days. Because it still exists and has not purged, a departure is reversible within the window: if the user changes their mind, the operator re-provisions them (Closed user set — only the operator can re-add a user) and the retained data reconnects to the restored account. Departure starts the purge clock; it does not, by itself, sever the data. This reversal is operator-mediated and whole-account — deliberately unlike the self-service, per-item “Recently Deleted” restore of Branch A.
- Purge. After 30 days, their data is purged. From that point it is gone irreversibly — the same finality as Lost credentials = lost data — and re-provisioning after purge is a fresh start, not a recovery. If they kept no export and the window has passed, only what they previously pulled survives.
Flow Diagram
flowchart TD
Start([User exercises control over their footprint]) --> Scope{Scope?}
Scope -->|Delete specific content| D1[Select & delete]
D1 --> D2[Disappears from library view immediately]
D2 --> D3[Moves to private 'Recently Deleted'<br/>— self-restore, 30 days]
D3 --> D4{Self-restore within window?}
D4 -->|Yes| Restored[Content restored by user]
D4 -->|No| DPurge[Permanently purged after 30 days]
Scope -->|Leave the system| L0{Voluntary, or operator<br/>termination for violation?}
L0 -->|Operator termination| LT[Access ends;<br/>no reversal window granted]
L0 -->|Voluntary| L1[Gated 'export first'<br/>informed acknowledgment]
L1 --> L2[Export complete archive<br/>— only while system is healthy]
L2 --> L3[Access ends]
L3 --> L4[Data retained 30 days<br/>— window against rushed departure]
L4 --> L5{Change of mind<br/>within window?}
L5 -->|Yes| Reprov[Operator re-provisions;<br/>retained data reconnects]
L5 -->|No| LPurge[Data purged — gone irreversibly;<br/>only prior exports survive]
LT --> LPurge
DPurge --> Gone((Gone for good))
LPurge --> GoneSuccess
A successful delete-and-leave experience leaves the user with:
- Their intent, realized safely. What they meant to remove is gone from their view immediately, and they were protected from their own mistakes by a real recovery window.
- No accidental loss. The 30-day net means a fat-fingered deletion or a rushed “I’m leaving” is recoverable — the capability’s Zero data loss promise holds even around destructive actions.
- A clean, honest departure (if they leave). They walked away with their archive in hand and a clear understanding that the system’s copy ends after 30 days — no false hope of an indefinite backup, and no nasty surprise.
- Confirmation of control. Deletion and departure both feel like their decision, fully in their hands, with the operator unable to peek at or resurrect content against the privacy posture.
Edge Cases & Failure Modes
- Accidental deletion. Experience-level handling: self-recoverable within the 30-day window from the private “Recently Deleted” surface (Branch A) — the user restores the item themselves, no operator request needed. This window is precisely what keeps intended deletion from ever becoming unintended data loss. (See the KPI note below for why deliberate deletion is not a KPI breach.)
- Two recovery mechanisms, not one. Experience-level handling: recovering an individually deleted item and recovering a whole departed account are deliberately distinct experiences, and the user should never confuse them. Deleted items live in the self-service, per-item “Recently Deleted” surface the user restores from alone; a departed account is brought back only through operator re-provisioning at whole-account granularity. Different scope (item vs. account), different agency (self-service vs. operator-mediated), different weight (casual undo vs. reversing a deliberate exit) — the experience keeps them from blurring so a user tidying their library never feels they are “leaving,” and a user reconsidering departure never expects a one-click item-style undo.
- Change of mind about leaving, within the window. Experience-level handling: a voluntary departure is reversible for the full 30 days, because the user’s data is retained and unpurged the whole time. Reversal runs through the operator (Closed user set — only the operator can re-add a user), and on re-provisioning the retained data reconnects to the restored account; departure starts the purge clock but does not sever the data on its own. The experience is honest about what it does promise: not a self-service, one-click return (re-entry is operator-mediated, a variant of Join as an Invited User), but the real assurance that “your data still exists, and the operator can bring you back to it, until the window closes.” After purge there is nothing to reconnect to, and return is a fresh start.
- Deleting content that was shared. If the user deletes an original they had shared, recipients lose the shared view (cross-reference Share Content and Receive Shared Content), but copies recipients already downloaded remain theirs. The experience is honest that deletion reaches the owner’s copy and the shared views of it, but not copies that already left.
- Operator-initiated removal for illegal content. Experience-level handling: this is treated differently from a voluntary departure — the reversible 30-day safety net does not apply. The retention window exists to protect a user from their own rushed or mistaken exit; it is not a courtesy hold owed to a user terminated on credible evidence of a No illegal content violation, and holding flagged content for 30 days would cut against the very reason for removing it. So a violation termination ends access without granting the user a reversal window, and the operator is free to remove the offending content on its own timeline rather than parking it in retention. The operator still cannot inspect content directly — termination acts on credible external evidence, not inspection — and there is no gated “export first” step for a removal the operator initiates. (A user’s voluntary departure remains fully reversible per the case above; the two paths are deliberately distinct.)
- Waiting too long to export on the way out. The complete export is available only while the system is healthy (Operator succession). A user who defers grabbing their archive until the system is down may find only previously-pulled exports survive. The experience should encourage exporting before leaving, not as a later step.
- Deleting an album vs. deleting content. Emptying or deleting an album is organizing, not deletion of content, and is handled in View and Organize Content. This journey is about deleting the underlying content itself. The two must stay clearly distinct so a user cannot destroy originals while merely tidying.
- Purge is truly irreversible. Once the 30-day window elapses, there is no operator recovery path — by design. The experience must not imply any “call the operator to get it back” escape hatch after purge; that would contradict both Lost credentials = lost data and the privacy posture.
Constraints Inherited from the Capability
This UX must respect the following items from the parent capability’s Business Rules and Success Criteria — named so future readers can trace the lineage:
- 30-day retention after deletion / departure. The spine of this entire journey. Deleted content and departed users’ data are retained for 30 days for accident recovery, then purged. Both branches are literal expressions of this rule.
- KPI — Zero data loss (precise reading). The KPI is: no user ever loses content they did not themselves delete. Deliberate deletion, and its permanent purge after the window, are expected behavior — not a KPI failure. The KPI is protected here by the recovery window (against accidents) and by the non-destructive-organizing guarantee elsewhere. The experience should make this distinction real: the user destroys their own content on purpose, with a safety net, and that is the system working as intended.
- Lost credentials = lost data. Departure and post-window purge share the same finality as losing credentials: once gone, gone, with no operator backdoor. This is the deliberate privacy trade-off, applied to the exit.
- Operator succession. The export-before-leaving step is the user-facing use of the capability’s on-demand-export mechanism, including the “only while the system is healthy” caveat. Exports are what preserve the user’s data across their departure.
- Closed user set. Only the operator adds or removes users. A user leaving, or being removed, and any later return, all pass through the operator — there is no self-service account deletion-and-recreation loop that bypasses this.
- Private by default. Throughout deletion, retention, and purge, the operator cannot see the user’s content. Retention is not an operator-readable archive; it is a private safety net that purges itself.
- Off-site backup is allowed. Because durability may rely on off-site replication, a deletion — and the eventual purge after the window — must reach those copies too. From the user’s seat, deleting is a single decision that takes effect everywhere; they should never have to wonder whether a backup somewhere still holds what they removed after the window closes.
Out of Scope
- Organizing (deleting albums, un-grouping). Non-destructive tidying is View and Organize Content, not deletion of content.
- The mechanics of exporting. Pulling the complete archive is described in View and Organize Content; this doc references it as the “export before you leave” step but does not re-specify it.
- Revoking a share without deleting the content. Taking back access while keeping the content is Share Content. This journey covers deletion of the underlying content (which also ends shared views of it).
- Re-onboarding after departure. If a departed user returns, their re-provisioning is a variant of Join as an Invited User, governed by the operator. Within the 30-day window that re-provisioning reconnects the user’s retained data (see Journey Branch B step 4); the mechanics of the join flow itself live in that experience, not here.
- Operator-side capacity reclamation. What the operator does with freed space after a purge is an operational concern, not part of the user’s experience.
Open Questions
None remaining. The five questions this journey previously carried have been resolved and folded into the sections above.
- Can a departed user return within the 30-day window and recover their data, and how? → Yes — departure is reversible for the full window; it starts the purge clock but does not sever the data. Because the data is retained and unpurged, the operator re-provisions the user (Closed user set) and the retained data reconnects to the restored account. Return is operator-mediated (a variant of Join as an Invited User), not one-click self-service; after purge there is nothing to reconnect to (Journey Branch B step 4, Edge Cases).
- Does the 30-day retention window apply to operator-initiated termination for illegal content? → No — a violation termination is handled differently and grants no reversal window. The retention net protects a user from their own mistaken exit; it is not owed to a user removed on credible No illegal content evidence, and the operator may remove offending content on its own timeline rather than hold it. The operator still cannot inspect content directly (Journey Branch B step 1, Edge Cases).
- Is there any distinction between “delete this content” recovery and “I left” recovery? → Yes — they are deliberately distinct. Deleted items are recovered by the user alone, per-item, from the private “Recently Deleted” surface; a departed account is recovered only through operator re-provisioning at whole-account granularity. Different scope, agency, and weight, kept from blurring so the two are never confused (Journey, Edge Cases).
- How is the user reminded to export before leaving, and how strongly? → Departure is gated behind an informed acknowledgment, with export offered right there — stronger than a mere offer, short of forcing the download. A voluntary departure cannot complete without passing an explicit “export first — the system’s copy ends after 30 days, this is your only lasting copy” step; the system cannot verify the file was kept, so the gate ensures the choice is informed and deliberate, with the 30-day window backstopping a rushed exit (Journey Branch B step 2).
- What exactly does a user see during the retention window? → A visible, private “Recently Deleted” surface they self-restore from — not invisible-but-recoverable-on-request. Deleted items appear there for the user alone (never the operator — Private by default), each showing time remaining before purge, and the user restores any item themselves with no operator involvement (Journey Branch A step 2, Edge Cases).
2.1.3 - Join as an Invited User
One-line definition: A person the operator has chosen is invited into the closed circle, sets up credentials only they hold, comes to genuinely understand the trade-offs they are accepting (private by default, no quotas, lost credentials = lost data), and ends up with a ready but empty library.
Parent capability: Self-Hosted Personal Media Storage
Persona
The actor here is a prospective user — a family member or friend the operator (Carson) has personally decided to invite. They map to the parent capability’s Primary actors, but at the very start of their relationship with the system, before they have stored anything.
- Role: A newly-invited member of the operator’s trusted circle. Assume they are not technical: they think in terms of “my photos” and “my account,” not credentials, keys, or recovery flows.
- Context they come from: They likely already keep their photos on a commercial cloud provider and have a vague unease about privacy, cost, or lock-in — or they simply trust the operator’s recommendation. They did not seek out and sign up for this system; they cannot, because there is no public sign-up. They are here because the operator reached out to them.
- What they care about here: Getting an account that is genuinely theirs, understanding in plain terms what they are agreeing to, and knowing what to do next. Underneath that: they want to feel they have been let into something private and trustworthy — not enrolled in a product that will mine their photos.
The operator is a secondary actor in this journey: they initiate and provision, but a load-bearing property of the whole capability is that the operator drives this onboarding without ever gaining the ability to see the user’s future content.
Goal
“I’ve been invited to this thing Carson runs. I want to get my own account set up, understand what I’m actually agreeing to, and know how to start putting my photos somewhere I trust — somewhere that’s really mine.”
The goal is not merely “an account exists.” It is “an account exists that only I control, and I understood the deal well enough to rely on it.” The understanding is part of the goal, because the capability’s central trade-off (lost credentials = lost data) only works if the user genuinely accepted it rather than clicking past it.
Entry Point
The journey begins out of band, on the operator’s initiative. The operator — in a conversation, a text, an in-person chat — tells the person they are being added and starts provisioning them. There is no invite link the person discovered, no “request access” button they pressed, no waitlist they joined.
This is a direct consequence of the Closed user set rule: the operator is the only person who can add a user, and there is no self-signup. The prospective user’s state of mind is therefore one of having been offered something by someone they trust, not of having shopped for a service. That trust is the currency the rest of the journey spends and must not betray.
Journey
The journey is a mostly-linear flow with one decision point (accept or decline) and a strong emphasis on a single comprehension beat in the middle — the moment the user actually understands the lost-credentials trade-off.
1. The operator invites and begins provisioning
The operator decides this person belongs in the circle and starts setting up their account. From the prospective user’s perspective, this is simply “Carson said he’s adding me.” Nothing is required of them yet.
Because provisioning is entirely operator-driven, the user is never asked to prove eligibility, pick a plan, or agree to terms-of-service boilerplate. The “terms” they will accept are the handful of real trade-offs surfaced in step 4, in plain language — not a legal document.
2. The person receives the invitation
The person perceives an invitation to set up their account — an unmistakable signal that it is their turn to act, arriving through whatever channel the operator uses. What matters at the experience level is that it is clearly for them, clearly from the operator they trust, and clearly time-bound to a setup action they take now.
The invitation is genuinely time-bound: it carries the credential the person uses to establish their account, and a setup credential that stays valid forever is a standing security liability. So an unaccepted invitation expires after a bounded window (on the order of a week — long enough that a real person acting in good faith is not rushed, short enough that a stale setup link does not linger). Expiry is quiet and carries no penalty or stigma: if the person let it lapse, they are still exactly as invited as before. The operator simply re-invites them — the same one-step action as the original invitation, producing a fresh time-bound setup credential. There is no distinct “expired, please retry” flow the user must navigate; from their seat, the invitation just arrives again.
3. Accept or decline
The person decides whether to join.
- Decline. The circle is closed and trust-based; there is no pressure and no consequence. Nothing further is provisioned, and the journey ends here. This is a legitimate outcome, not a failure — a person choosing not to store their photos here is simply not part of the user set.
- Accept. They proceed to establish their credentials.
4. Establish credentials only they hold — and understand what that means
The person sets up the credentials they will use to sign in. The defining property, which they must come to understand, is that only they hold these credentials. The operator does not learn them and cannot reset them into any form that would expose the user’s content.
This is the comprehension beat — the emotional and conceptual center of the whole onboarding. Before the user relies on the system, they are walked, in plain terms, through the deal they are accepting:
- Their content will be private by default. No third party — including the operator who runs the system — can see their photos unless they explicitly share them. This is stated as a promise, not a setting.
- There are no storage limits. They will never be told they are out of space or asked to pay for more. (For someone leaving a commercial provider, this is a pleasant surprise worth naming.)
- They can always get their stuff out. They can pull a complete copy of everything they own, on their own, whenever the system is healthy — and they are encouraged to do so periodically. This is their safety line and their exit.
- If they lose their credentials, their data is gone. This is the hard one. The operator cannot rescue them — not “won’t,” can’t — because the same property that keeps the operator out of their content also keeps the operator from recovering it. This is a deliberate Signal-style trade-off in service of privacy.
The experience-level obligation here is informed consent: the user should leave this step genuinely understanding the no-recovery trade-off and knowing they are responsible for safeguarding their credentials (e.g. keeping them in a password manager). Burying this in fine print would be a failure of the journey, because a user who did not understand it will later experience an unrecoverable loss as a betrayal rather than a known trade-off they accepted.
The confirmation itself is a simple explicit acknowledgement — after the plain-language explanation, the user affirms once that they understand losing their credentials means losing their data. It is deliberately not a quiz, a typed recovery-phrase re-entry, or any other gate: the trusted-circle relationship is the point, and turning onboarding into a bureaucratic checkpoint would betray it. This does place real weight on the explanation rather than the mechanic — the load-bearing consent work is the operator’s plain-language walkthrough and the honest framing above, not the tap that follows it. That is a conscious trade-off in favor of a low-friction, trust-based experience, accepting the residual risk that a determined user can still click past a simple acknowledgement.
5. First sign-in to a ready, empty library
The person signs in for the first time and lands in their own space: private, theirs, and empty. The emptiness is an invitation — the system points them at what to do next.
6. Pointed toward first content
The account is ready, so the journey hands off to the user’s first real use. They are oriented toward the two natural next steps: uploading content (a first photo, or turning on automatic device backup) or bulk-importing their whole existing library from a prior provider. This handoff is where “provisioned” starts becoming “active.”
This handoff is deliberately passive direction rather than active hand-holding: the empty library clearly points at those two next steps, but the system does not march the user through a scripted first upload. Even for a non-technical person, an obvious, plainly-labelled pair of choices respects their autonomy and keeps onboarding from feeling like a wizard that won’t let them out. The trade-off is a higher risk that some users land and stall; that risk is not solved by force here but watched — dormancy is tracked through the Number of active users KPI, and chronic stalling is a signal to revisit this choice rather than a reason to bolt on a mandatory tour.
Flow Diagram
flowchart TD
Start([Operator decides to invite this person]) --> Provision[Operator begins provisioning<br/>account — no self-signup]
Provision --> Invite[Person receives invitation to set up]
Invite --> Decide{Accept?}
Decide -->|Decline| End[Nothing provisioned.<br/>Journey ends — a legitimate outcome]
Decide -->|Accept| Creds[Person establishes credentials<br/>only they hold]
Creds --> Understand[Comprehension beat:<br/>private by default · no quotas ·<br/>can always export · lost credentials = lost data]
Understand --> FirstLogin[First sign-in →<br/>ready, empty, private library]
FirstLogin --> NextStep[Oriented toward first content:<br/>upload or bulk-import]
NextStep --> Active((Poised to become an active user))Success
A successful onboarding leaves the person with:
- An account only they control. The credentials are theirs alone; the operator provisioned access without acquiring the ability to read their content.
- Genuine understanding of the trade-off. They did not merely click “I agree” — they can state, in their own words, that losing their credentials means losing their data, and that this is the price of nobody (including the operator) being able to snoop. They accepted it knowingly.
- A clear next step. They know how to start — upload a photo, turn on backup, or import their old library — so the empty library is a starting line, not a dead end.
Emotionally, success feels like being let into a trusted, private circle — the opposite of signing up for a product. The person’s trust in the operator has been honored, not spent down.
Edge Cases & Failure Modes
- The person declines the invitation. Handled in the journey: nothing is provisioned, no pressure is applied, and this is recorded simply as “not a user.” The closed, trust-based nature of the circle makes declining unremarkable.
- The invitation lapses before the person acts. A good-faith invitee who got busy and let the setup window pass is not a failure state. The invitation simply expires, and the operator re-invites with the same one-step action, issuing a fresh setup credential. The user experiences a second invitation arriving, not an error to recover from. Distinguishing an innocent lapse from a soft decline is not the flow’s job — either way the remedy is identical and cost-free (re-invite, or don’t).
- The person loses their credentials immediately after setup, before storing anything. The loss is genuinely unrecoverable — but the stakes are near zero because the library is empty. Experience-level handling: treat this as a low-cost teaching moment. The operator can re-provision a fresh account (the operator can always add a user), but this is a new account, not a recovery of the old one — consistent with lost-credentials-equals-lost-data. The value of losing an empty account is that it reinforces the trade-off before anything is at stake.
- The person never uses the account after provisioning. They become a dormant provisioned user, not an active one, per the Number of active users KPI. Experience-level handling: this is not an error state to fix inside the flow, but the journey should end by pointing clearly at a first action, precisely so that provisioned-and-idle is the exception rather than the default. Chronic dormancy is a signal the capability isn’t meeting the person’s real need — it is tracked, not papered over.
- The person doesn’t understand, or is uncomfortable with, the no-recovery deal. This is the most important failure mode to get right. Experience-level handling: the honest response is to make sure they understand before they rely on the system — informed consent is part of onboarding, not fine print. If, having understood it, they are not comfortable with it, that discomfort is legitimate and may mean this system isn’t right for them. The wrong handling would be to soften or hide the trade-off to keep them; that would only convert a clear up-front choice into a future betrayal.
- The operator later removes the person from the circle. Only the operator can do this (closed user set). From the user’s side this is a departure, and it is governed by the same 30-day retention mechanics as leaving voluntarily — see Delete Content and Leave. It is out of scope for the onboarding journey itself.
- Someone tries to join without an operator invite. There is no path for this — there is no front door to knock on. Not an edge case the flow handles so much as a property of the closed user set that this journey depends on.
Constraints Inherited from the Capability
This UX must respect the following items from the parent capability’s Business Rules and Success Criteria — named so future readers can trace the lineage:
- Closed user set. The entire entry point is operator-initiated. There is no self-signup, no invite link the user found on their own, no request-access flow. The operator is the sole party who can add a user, and this journey is the operationalization of that rule from the invited person’s seat.
- Lost credentials = lost data. This is the conceptual center of the journey (step 4). The onboarding’s job is not just to create credentials but to secure the user’s informed consent to the fact that losing them is unrecoverable, because the operator cannot reset access in a way that exposes content. Getting this comprehension right at onboarding is what makes a later loss a known accepted trade-off rather than a support failure.
- Private by default. Surfaced explicitly during onboarding as a promise: the operator who is provisioning the account cannot see the content that will live in it, and no other user can either, absent an explicit share. The user should leave onboarding believing this, because it is true.
- No storage quotas. Named during the comprehension beat as a concrete benefit, especially salient for someone migrating off a metered commercial provider.
- Operator succession. The user-facing half of this rule — “you can pull a complete on-demand archive of your own content, and you should do so proactively while the system is healthy” — is introduced here as the user’s standing safety line and exit path. The sealed-successor half is an operator concern and not surfaced to the user.
- KPI — Number of active users. This journey is the top of the funnel for that KPI. Provisioning a user is necessary but not sufficient; the metric counts users who actually {upload, view, download, share}. Ending the flow by pointing at a concrete first action is how onboarding contributes to active rather than merely dormant counts.
- KPI — Zero data loss. A precise boundary matters here: a user losing their own credentials is not a violation of this KPI. The KPI is about the system never losing content the user did not themselves delete or lose access to. Onboarding must convey this distinction so the guarantee (“we won’t lose your stuff”) isn’t heard as a promise the trade-off contradicts (“even if you lose your key”).
Out of Scope
- The user’s first upload or import. Onboarding ends at a ready, empty library pointed at the next step. The actual first-content journeys live in Upload Content and Bulk-Import from a Prior Provider.
- The operator’s provisioning mechanics. How the operator stands up an account (identity technology, invitation delivery, account creation steps) is a design and operational concern, not part of the user’s experience. This doc only covers what the invited person perceives.
- Removal / departure and the 30-day retention window. When the operator removes a user, or a user chooses to leave, that is Delete Content and Leave. This journey only notes that operator-initiated removal exists and is governed there.
- Credential recovery. Explicitly excluded by the capability itself. There is no recovery journey to document; the absence is the design.
- Sharing and being shared with. A brand-new user with an empty library is neither sharing nor receiving yet. Those are Share Content and Receive Shared Content.
Open Questions
None remaining. The three questions this journey previously carried have been resolved and folded into the sections above:
- Depth of the comprehension check → a simple explicit acknowledgement after the plain-language explanation, deliberately not a quiz or gate (Journey, step 4).
- Invitation expiry and re-invitation → unaccepted invitations expire after a bounded window, and the operator re-invites with the same one-step action, no penalty (Journey, step 2 and Edge Cases).
- Guided vs. self-directed first step → passive direction: the empty library points at upload/import and lets the user proceed, with dormancy watched via the active-users KPI rather than forced away (Journey, step 6).
2.1.4 - Receive Shared Content
One-line definition: A user who has been granted access to someone else’s content finds it, views it, downloads the copies they want, and understands the boundaries of what’s been shared with them.
Parent capability: Self-Hosted Personal Media Storage
Persona
The actor is a recipient — the capability’s Secondary actor / consumer: an existing, provisioned user whom a content owner (see Share Content) has explicitly granted access to specific content, individually or via a shared group/album. This journey is the mirror image of Share Content, seen from the other side.
- Role: A user on the receiving end of a share — the grandparent who was given access to the new baby photos, a family member in the shared “family album.” They are a full user in their own right (they have their own library), but in this journey they are consuming someone else’s content, not managing their own.
- Context they come from: Someone in the circle decided to show them something. They arrive either because the shared content surfaced for them, or because they were told out of band (“check the album, I added photos”).
- What they care about here: Seeing what was shared, keeping the parts they care about, and understanding the relationship — what is theirs versus what they are merely being shown — without confusion.
Goal
“Someone in our circle shared photos with me — I want to see them, keep the ones I care about, and know what I’m allowed to do with them.”
Entry Point
The recipient becomes aware that content has been shared with them. Critically, they arrive at shared content — they never arrive by browsing the owner’s private library, because they cannot: they only ever see what was explicitly shared to them (see Constraints Inherited — Private by default). Awareness comes in one of two ways, and the system leans on the first as its baseline: the shared content simply appears in a distinct “shared with me” surface the next time the recipient looks (a pull signal they discover on their own terms), optionally reinforced by an out-of-band nudge from the owner (“check the album, I added photos”). The system deliberately does not push a dedicated alert, email, or notification of its own — it neither operates a notification channel nor reveals recipient presence/activity back to the owner, keeping sharing a quiet relationship inside the circle rather than an eventful one. The trade-off is accepted openly: a recipient who never looks may not notice a share until they next visit, and nudging is left to the people, not the system.
Journey
- Notice the shared content. The recipient perceives that new content has been shared with them, kept clearly distinct from their own library so the two never blur together.
- View it. They look through what was shared — the photos, the video, the album — as a guest of that content, not as its owner.
- Download what they want to keep. For items they want a lasting personal copy of, they download the original. A downloaded copy becomes their own content, now living under their control (and their own deletion/retention behavior if they later remove it). The copy carries no system-enforced provenance — once saved it is indistinguishable from the recipient’s own uploads, with no “originally shared by X” marker the system maintains or surfaces. This is the clean, honest consequence of the boundary model: a downloaded copy is fully theirs, not a tracked derivative the owner still has a thread into. (If a recipient wants to remember where a photo came from, that is theirs to note; the system does not do it for them, and does not report the download back to the owner.)
- Understand the boundaries. The recipient understands, through how the experience behaves, that:
- the content still belongs to the owner;
- the owner can revoke the shared view at any time, at which point the recipient sees a plain “no longer shared” state rather than a silent disappearance (see step 5 of Share Content and Edge Cases below);
- a copy they downloaded is theirs and is not revoked when the owner revokes the share;
- removing a shared item from their own view does not delete the owner’s original — they can only dismiss it from their side.
- Keep shared and owned separate. Throughout, “shared with me” stays visually and conceptually separate from “my own library,” so the recipient never mistakes one for the other.
Flow Diagram
flowchart TD
Notice([Content shared with the recipient]) --> Distinct[Appears separate from their own library]
Distinct --> View[View as a guest of the content]
View --> Keep{Want a lasting copy?}
Keep -->|Yes| Download[Download original —<br/>becomes recipient's own content]
Keep -->|No| Leave[Just view it]
Download --> Owned((Personal copy under recipient's control))
Leave --> Boundary
Owned --> Boundary[Understands: owner still owns the original]
Boundary --> Change{Owner revokes or deletes original?}
Change -->|Revokes share| GoneView[Shared view disappears —<br/>downloaded copies unaffected]
Change -->|Deletes original| GoneView
Change -->|No change| Stay[Shared view remains available]Success
A successful receive-shared experience leaves the recipient with:
- What was meant for them, seen clearly. They viewed exactly what the owner intended to show, with no friction and no accidental exposure to the owner’s other content.
- The copies they wanted, under their own control. Anything they chose to keep is now genuinely theirs.
- An accurate mental model. They understand the borrowed-view-versus-owned-copy distinction, so nothing about a later revocation or deletion feels like a betrayal or a bug — it matches what they already understood.
- No clutter or confusion. Shared-with-me content never contaminates their own library.
Edge Cases & Failure Modes
- The owner revokes access. Experience-level handling: the recipient’s shared view is removed going forward, but it does not simply blink out of existence — the recipient sees a plain, neutral “no longer shared” state, enough to read the change as intentional (“the owner changed their mind”) rather than a bug or a loss. That message is deliberately incurious: it does not reveal why access ended, and it does not distinguish a revoke from the owner deleting the original — so a recipient can never reverse-engineer the owner’s private actions from it. Anything they already downloaded remains theirs; revocation does not reach copies that already left the owner’s control. This is the recipient-side mirror of the resolution in Share Content.
- The owner deletes the original. The shared view goes away for the recipient (consistent with revocation); a copy the recipient already downloaded is unaffected. Cross-reference: Delete Content and Leave.
- Confusing shared-with-me for my-own. The recipient must never believe that deleting a shared item removes the owner’s original — it does not. They can only remove it from their view. The experience keeps this unambiguous so a recipient “cleaning up” cannot imagine they are affecting someone else’s library.
- A recipient loses their own credentials. The Lost credentials = lost data trade-off applies to the recipient exactly as to any user: copies they downloaded and keep live in their account, and are as unrecoverable as anything else they own if they lose access. Being a recipient does not exempt them from the capability’s core trade-off.
- Wanting to re-share onward. Experience-level handling: re-sharing the owner’s shared view is not possible — a recipient cannot forward, re-share, or add anyone else to what was shared with them. This preserves the owner’s guarantee that content reaches only the people the owner explicitly named. The one path by which shared content ever reaches someone new runs through the boundary the capability already accepts: the recipient downloads it, at which point the downloaded copy is their own content, and they may share that copy as its new owner — a fresh share of a distinct copy they own, not the owner’s original share propagating onward (the owner’s original and its access list are untouched). This is the single, consistent answer shared with Share Content.
- A downloaded copy carries no provenance. Experience-level handling: once the recipient downloads and keeps content, it becomes indistinguishable from their own uploads — the system attaches no “originally shared by X” marker and maintains no ongoing link back to the owner. This is intentional: it keeps the copy genuinely the recipient’s, avoids a lingering tracking thread that would sit oddly against the no-surveillance posture, and matches the clean owned-vs-borrowed split the rest of the journey relies on. Any memory of where a copy came from is the recipient’s to keep, not the system’s to enforce.
- Never seeing the owner’s other content. No path in this journey ever exposes the owner’s broader private library. The recipient’s world is bounded by what was explicitly shared.
Constraints Inherited from the Capability
This UX must respect the following items from the parent capability’s Business Rules and Success Criteria — named so future readers can trace the lineage:
- Private by default. The recipient sees only what was explicitly shared to them — never the owner’s wider library. This rule defines the entire boundary of the recipient’s experience. The operator, likewise, sees nothing extra as a result of any share between users.
- Closed user set. The recipient is themselves a provisioned member of the circle; only users can be on the receiving end of a share. There is no anonymous or public recipient.
- Lost credentials = lost data. Applies to the recipient’s own copies. Content they download and keep is theirs, protected by their own credentials, with the same no-recovery trade-off as any user’s content.
- No affected-party recourse process. If the recipient is also someone depicted in content shared by another, they have no special in-system removal right by virtue of being depicted; that is resolved interpersonally, per the capability. Their rights in this journey come from being an explicitly-named recipient, not from being a subject.
- KPI — Number of active users. Viewing and downloading shared content are two of the four counted active-user actions ({upload, view, download, share}). A recipient who regularly engages with shared content is an active user, even in periods when they upload nothing of their own — shared consumption is a legitimate, counted form of getting value from the system.
- KPI — Zero data loss (precise reading). This KPI is about a user losing content they own and did not delete. A recipient losing a shared view when the owner revokes or deletes is not a data-loss failure — the recipient never owned that view; they were a guest. Only the recipient’s own downloaded copies fall under the KPI for them. The experience should reflect this so a vanished shared view reads as “the owner changed their mind,” not “the system lost my data.”
Out of Scope
- The owner’s side of sharing. Deciding to share, choosing recipients, and revoking are Share Content. This doc is only the recipient’s experience.
- Managing the recipient’s own library. Once a recipient downloads a copy, browsing/organizing/exporting it is View and Organize Content, and deleting it is Delete Content and Leave — the same journeys as for any content they own.
- Becoming a user in the first place. The recipient is already provisioned; onboarding is Join as an Invited User.
- Group administration. How shared groups (e.g. a family album) are created and who manages membership is a shared concern surfaced in Share Content, not resolved here.
Open Questions
None remaining. The four questions this journey previously carried have been resolved and folded into the sections above. The re-sharing and revocation-visibility questions are shared with Share Content; the answers below are the single, consistent resolution both journeys use.
- May a recipient re-share content shared with them? → No — re-sharing the owner’s shared view is blocked. A recipient cannot forward, re-share, or add anyone else to what was shared with them. Content reaches someone new only when the owner shares it directly, or when the recipient downloads it: a downloaded copy becomes the recipient’s own content, which they may then share as its new owner. The owner’s original share never propagates onward (Edge Cases). (Shared with Share Content.)
- How is the recipient notified of a new share? → A pull-based “shared with me” surface, optionally reinforced by an out-of-band nudge from the owner — no dedicated system alert. Shared content appears in a distinct surface the recipient finds when they next look; the system pushes no alert of its own and reports nothing about the recipient back to the owner. The accepted trade-off is that a recipient who never looks may not notice until their next visit (Entry Point).
- What does the recipient see when a share is revoked or the original deleted? → A graceful, neutral “no longer shared” state, not a silent disappearance — enough to read the change as intentional, without revealing why access ended or distinguishing a revoke from a delete, and with any already-downloaded copies left untouched (Journey step 4, Edge Cases). (Shared with Share Content.)
- Does downloaded shared content carry provenance? → No. Once downloaded and kept, a copy is indistinguishable from the recipient’s own uploads — the system attaches no “originally shared by X” marker and keeps no ongoing link to the owner. The copy is genuinely the recipient’s, consistent with the download boundary and the no-surveillance posture (Journey step 3, Edge Cases).
2.1.5 - Share Content
One-line definition: A content owner grants a specific fellow user, or a shared group/album, access to chosen content — and can review and revoke that access later — while everything else stays private.
Parent capability: Self-Hosted Personal Media Storage
Persona
The actor is a content owner — a Primary actor from the parent capability’s Stakeholders — who wants to let someone else in the trusted circle see specific media of theirs. The recipient’s side of this interaction is a separate journey (Receive Shared Content); this doc is strictly the owner deciding to share and staying in control of that decision.
- Role: A user who owns content and wants a specific person, or a defined group, to see specific items — a grandparent seeing the new baby photos, a “family album” the whole family can see.
- Context they come from: They are in their own library (View and Organize Content) and something there is worth showing to someone. Their reference point is the commercial photo app’s sharing — but with a sharper expectation of privacy, because privacy is why they left.
- What they care about here: That exactly the people they intend can see exactly what they chose — no more — that the rest of their library stays private, and that they can take access back later if they change their mind.
Goal
“I want just these photos seen by just this person — or our family album — nobody else, and I want to be able to take that access back later.”
Entry Point
The owner is in their own library and decides that some specific content should be seen by someone specific. The trigger is social, not systemic: a moment worth sharing, a person who would want to see it, an album the family keeps together. They arrive already knowing what they want to share and roughly with whom.
Journey
- Select the content. The owner picks the specific item(s) to share — one photo, a set, a whole album’s worth. Everything they do not pick stays private; sharing is strictly additive and scoped to the selection.
- Choose the recipient. The owner picks who gets access, in one of two shapes:
- One-to-one — another named existing user in the circle.
- A shared group — e.g. a “family album” that a defined set of users can see. A shared group is owned and administered by the user who creates it, not by the operator: that owner draws the group’s members from the closed user set and decides what content the album holds (members and content can change over time). The operator’s only role stays provisioning users into the circle — group administration is a user-level act, not an operator burden. In both shapes, the recipient must already be a user (see Constraints Inherited — Closed user set). There is no sharing to an email address, a public link, or anyone outside the invited set.
- Grant access. The owner shares. The content becomes visible to the chosen recipient(s) and to no one else.
- See exactly who can now see it. The owner gets a clear, truthful picture of who has access to this content as a result — so “who can see this?” always has an answer they can check, not a vague sense that they shared it “somewhere.”
- Review and revoke later. At any later point the owner can look at what they have shared and with whom, and revoke access. Revocation removes the recipient’s shared view going forward. On the recipient’s side the view does not simply blink out of existence — they see a plain “no longer shared” state (see Receive Shared Content), enough to read the change as intentional. That message is deliberately neutral: it does not reveal why access ended or distinguish a revoke from a deletion, so revoking never leaks the owner’s private actions.
A note on what revocation can and cannot do
Revocation reliably removes the shared view. It cannot reach a copy a recipient already downloaded — once a recipient saves a copy, that copy is theirs (see Receive Shared Content). The experience must be honest about this boundary so the owner shares with correct expectations rather than a false sense of total recall.
Crucially, the system does not report a recipient’s activity back to the owner — the owner is not notified when a recipient views or downloads shared content. Sharing here is a relationship inside a trusted circle, not a surveillance channel over the people the owner chose to share with; feeding the owner a log of recipient views would erode those recipients’ privacy exactly as the operator’s non-visibility protects everyone else’s. Because the owner therefore cannot know whether a copy was already downloaded before a revoke, the honest posture is the same one the download boundary already demands: treat anything shared as potentially already kept. This makes “notify on download” not just unnecessary but beside the point — the owner should share only what they are comfortable leaving in a recipient’s hands, and the experience sets that expectation up front rather than offering after-the-fact tracking.
Flow Diagram
flowchart TD
Start([In their library, owner decides to share]) --> Select[Select specific content]
Select --> Who{Choose recipient}
Who -->|One person| Named[A named existing user]
Who -->|A group| Group[A shared group / family album]
Named --> Closed{Recipient already a user?}
Group --> Closed
Closed -->|No| Invite[Can't share outside the circle —<br/>operator must invite them first]
Invite --> Who
Closed -->|Yes| Grant[Grant access]
Grant --> Confirm[Owner sees exactly who can now view it]
Confirm --> Live((Recipient can see it;<br/>rest of library stays private))
Live -->|Change of mind| Revoke[Review shares & revoke]
Revoke --> Note[Shared view removed going forward —<br/>already-downloaded copies remain with recipient]
Note --> LiveSuccess
A successful share leaves the owner with:
- Precisely-scoped visibility. Exactly the intended people can see exactly the intended content, and nothing else of theirs leaked.
- A clear, checkable picture of access. They know who can see what, and can revisit it — sharing never becomes a fog of “who did I show this to?”
- Retained control. They can revoke, and they understand the one honest limit (downloaded copies), so their expectations match reality.
- Undisturbed privacy everywhere else. The act of sharing one thing did not weaken the privacy of everything else; their library is still private by default.
Edge Cases & Failure Modes
- Sharing with someone who isn’t a user yet. Experience-level handling: not possible directly. The owner can only choose from people already in the Closed user set. To share with someone new, that person must first be invited and provisioned by the operator (Join as an Invited User); only then can the owner share with them. There is no public link and no external-email share — this is a deliberate consequence of the capability’s Public sharing being out of scope.
- Revoking after a recipient has downloaded. The shared view disappears, but a downloaded copy is now the recipient’s own and is beyond the owner’s reach. The experience states this plainly rather than implying revocation claws back copies.
- A recipient wants to pass the content on. Experience-level handling: re-sharing the owner’s shared view is not possible — a recipient cannot forward, re-share, or add someone else to what was shared with them. This keeps the owner’s guarantee intact: content reaches only the people the owner explicitly named. The one path by which shared content ever reaches someone new runs through the honest boundary the capability already accepts — a recipient downloads it, at which point the downloaded copy is their own content, and they may share that copy as its new owner. That is a fresh share of a distinct copy the recipient owns, not the owner’s original share propagating onward; the owner’s original and its access list are untouched. This is the single, consistent answer shared with Receive Shared Content.
- Shared-group membership changes. A shared group is administered by its owner (the user who created it — see Journey step 2), who adds and removes members from the closed user set. When someone is added to or removed from the group, the experience makes clear how that affects who can see content already shared to the group — so the owner is never surprised by a new group member gaining access to old content, or a removed member losing their view going forward (a removed member is revoked exactly as an individual revoke, and any copy they already downloaded remains theirs).
- A person depicted in the shared media objects. There is No affected-party recourse process in the system — a non-user depicted in a photo has no system-provided way to force its removal, and even a user who is merely depicted (not the owner) has no override. Such objections are expected to be resolved interpersonally, outside the system; the owner decides. The sole exception is the operator’s termination lever under No illegal content: where a depiction is itself illegal in the operator’s jurisdiction, the operator may act on credible evidence — but the operator still cannot inspect content directly. The experience should not imply any in-system “report this share” flow beyond that narrow, operator-driven lever.
- The operator is not a privileged viewer. Sharing among users never makes content visible to the operator. The operator sees a user’s content only if that user explicitly shares it to the operator, exactly as they would with anyone else.
- Over-broad selection. If the owner accidentally selects more than they meant (e.g. a whole album when they wanted one photo), the review-access step is their safety check — they can see the full scope of what they just exposed and pull it back before it matters.
Constraints Inherited from the Capability
This UX must respect the following items from the parent capability’s Business Rules and Success Criteria — named so future readers can trace the lineage:
- Private by default. This is the rule the whole journey turns on. All content is private to its owner unless the owner explicitly shares it. Sharing is the only mechanism by which anyone else — including the operator — ever sees a user’s content. Nothing about this journey erodes the default; it is a scoped, deliberate exception the owner controls.
- Closed user set. Recipients must be members of the invited circle. Only the operator adds or removes users; an owner cannot conjure a recipient. There is no public sign-up and, correspondingly, no public sharing — Public sharing is explicitly out of scope in the capability.
- No affected-party recourse process. People depicted in shared media have no system-provided removal path; objections are resolved interpersonally. This is a deliberate capability decision, not a gap this UX should try to fill.
- No illegal content. The single operator lever that can override an owner’s sharing is termination on credible evidence of illegal content, governed by the operator’s jurisdiction. This UX surfaces that lever precisely and does not overstate it into general operator moderation of shares.
- Lost credentials = lost data. Sharing does not create an operator backdoor or a recovery path. A recipient still holds their own credentials, and the privacy posture (no operator visibility without explicit share) is preserved throughout.
- KPI — Zero data loss. Reviewed here for the guarantee it imposes on this journey: sharing and revoking are non-destructive to the owner’s content. Sharing is purely additive — it grants visibility, and never moves, copies out, or deletes the underlying item — so it cannot cause the owner to lose anything. Revoking a share must remove visibility, never the content itself; a revoke that deleted the owner’s original would be a zero data loss failure hiding inside a sharing action. The one place data does leave the owner’s control — a recipient’s downloaded copy surviving revocation — is not a loss of the owner’s data (their original is untouched); it is a limit on recall, addressed honestly in the revocation note above. This journey therefore has no path that undermines the zero-data-loss guarantee.
- KPI — Number of active users. Sharing is one of the four counted active-user actions ({upload, view, download, share}). A circle that actually shares with each other is a circle getting real value from the system — sharing is arguably the strongest signal that the capability is meeting a social need the old provider met.
Out of Scope
- The recipient’s experience. What the recipient sees, does, and is allowed to do with shared content is Receive Shared Content. This doc stops at the owner’s side of the interaction.
- Selecting and browsing content to share. Finding and picking the content happens in View and Organize Content; this doc picks up once the owner has decided to share.
- Provisioning a new recipient. If the intended recipient isn’t a user yet, inviting them is the operator’s job, covered by Join as an Invited User.
- Deleting shared content. What happens to a share when the owner deletes the underlying content (recipients lose the view; downloaded copies remain) is covered by Delete Content and Leave.
- Public sharing of any kind. Sharing outside the invited circle is explicitly out of scope for the entire capability and therefore impossible in this journey.
Open Questions
None remaining. The four questions this journey previously carried have been resolved and folded into the sections above. The first and last are shared with Receive Shared Content; the answers below are the single, consistent resolution both journeys use (Receive Shared Content adopts them in its own doc):
- Can a recipient re-share content? → No — re-sharing the owner’s shared view is blocked. Content reaches someone new only when the owner shares it directly, or when a recipient downloads it: a downloaded copy becomes the recipient’s own content, which they may then share as its new owner. The owner’s original share never propagates onward, preserving “exactly the people the owner intends” (Edge Cases). (Shared with Receive Shared Content.)
- Is the owner notified when a recipient views or downloads? → No. The system does not report recipient activity back to the owner — sharing is a trusted-circle relationship, not a surveillance channel. The owner is instead guided to treat anything shared as potentially already-downloaded-and-kept, which is the honest posture the revocation limit already demands (see the revocation note under Journey).
- Who owns and administers a shared group? → The user who creates it — not the operator. That owner draws the group’s members from the closed user set and decides what content the album holds; the operator’s only role stays provisioning users into the circle (Journey step 2, Edge Cases).
- What does a recipient see when access changes? → A graceful “no longer shared” state, not a silent disappearance — neutral enough to read as an intentional change, without revealing why or distinguishing a revoke from a delete, and with any already-downloaded copies left untouched (Journey step 5). (Shared with Receive Shared Content.)
2.1.6 - Upload Content
One-line definition: A provisioned user gets their media into their own storage — sometimes a specific file they deliberately choose, sometimes automatically from their device without thinking about it — and trusts it landed safely and privately.
Parent capability: Self-Hosted Personal Media Storage
Persona
The actor is an active user — one of the Primary actors (initiators) named in the parent capability’s Stakeholders: the operator, a family member, or a friend, acting on their own content. They have already been invited and provisioned (see Join as an Invited User); this journey is about the everyday act that makes the account worth having.
- Role: An everyday user putting their own photos, videos, and files into their own storage. Non-technical is the default assumption — they think in terms of “my photos,” not “objects” or “buckets.”
- Context they come from: They just took a photo, shot a video, or have a file on a device they want kept safely. Their prior mental model is a commercial cloud provider’s camera-roll sync — the thing they are trying to replace. They expect “it just backs up” to be the baseline, not a feature they have to earn.
- What they care about here: That the content they intend to keep is actually kept, that it is private to them, and — for automatic backup — that it keeps happening without their attention so they never lose a memory to a dropped, lost, or upgraded device.
Goal
“I want my photos, videos, and files safely in my own storage — sometimes a specific thing I pick, sometimes just automatically from my phone — so I never lose a memory, and so nobody but me can see it.”
Entry Point
There are two distinct entry points into the same journey, and the difference in the user’s state of mind is the whole reason both belong in one doc:
- Manual upload (deliberate, attended). The user has a specific thing in mind — a photo they want off a borrowed camera, a document they want kept, a video someone sent them. They come to the system on purpose and expect to watch it land.
- Automated device backup (passive, set-and-forget). The user configured backup once, at some earlier moment, and is now not thinking about the system at all. New captures on their device are expected to flow in on their own. The user only re-engages to occasionally reassure themselves it is working — or when something makes them doubt it.
Both entries share the same goal (content stored, private, durable) and the same success condition (trust that it landed). They diverge only in how much attention the user is paying.
Journey
Branch A — Manual upload
- Choose the content. The user picks the specific item(s) they want stored — one photo, a handful, a large video, an arbitrary file. There is no size or count ceiling they have to plan around (see Constraints Inherited — No storage quotas).
- Send it. The user starts the upload and stays with it — this is the attended case.
- Perceive progress. For anything that takes more than a moment (a long video, a batch), the user sees that it is progressing and roughly how much remains. Progress is legible enough that they are not left wondering whether the system is stuck.
- Get an unambiguous confirmation. When it is done, the user sees a clear signal that the content is now durably stored and private to them. This confirmation is load-bearing: it is what lets them safely delete the copy on their device. A vague or premature “done” that isn’t actually durable would quietly threaten the Zero data loss KPI, so the confirmation must mean what it says.
Branch B — Automated device backup
- Turn it on once. At some earlier point the user enables backup for their device. This is a one-time, deliberate act; everything after is passive. What backup covers is deliberately not a folder-by-folder configuration exercise: enabling it backs up the device’s photos and videos — the camera roll, the irreplaceable memories that match how this user thinks (“my photos,” not “my filesystem”) — as a single on/off per device. Arbitrary non-media files are not swept up automatically; when the user deliberately wants a specific document or file kept, that is the manual path (Branch A), which already accepts any file. Keeping the automatic scope to media as one toggle protects the “not having to think about it” quality for a non-technical persona — a per-folder selector would trade that calm for configuration the user never asked for.
- New captures flow in on their own. From then on, new photos and videos taken on the device are picked up automatically, in the background, without the user initiating anything. The user’s expectation — inherited from the commercial product they are replacing — is “I take a photo, and later it is just there in my storage.”
- Reassure at a glance. When the user does look, they can quickly confirm that recent captures are backed up and see whether anything is still pending — enough to trust the mechanism without having to audit it item by item. The default signal is aggregate, not per-item: a plain “all caught up as of
- Catch up after being offline. If the device was offline, out of range, or powered off for a while, backup resumes and catches up when connectivity returns. The user does not have to babysit it or manually re-trigger the missed items.
Where the branches meet
Whichever entry the user came through, the end state is identical: the content is in their private storage, it counts as their content under every rule the capability defines, and the act of putting it there registered them as an active user for that period.
Flow Diagram
flowchart TD
subgraph Manual [Branch A — Manual upload]
M1[User has a specific item in mind] --> M2[Choose the content]
M2 --> M3[Send it, stay with it]
M3 --> M4{Progress visible?}
M4 -->|Interrupted| MInt[Perceived as paused]
MInt --> MResume[Resumes from where it stopped]
MResume --> M5
M4 -->|Completes| M5[Clear confirmation:<br/>durably stored & private]
end
subgraph Backup [Branch B — Automated device backup]
B1[User enabled backup once, earlier] --> B2[New captures flow in automatically]
B2 --> B3{Device online?}
B3 -->|Offline a while| B4[Queues; catches up on reconnect]
B4 --> B2
B3 -->|Online| B5[Recent captures backed up]
B5 --> B6[User glances, reassured]
end
M5 --> Stored((Content in private storage —<br/>counts as an active-user action))
B6 --> Stored
Stored --> Trust[User trusts it landed;<br/>safe to free device storage]Success
A successful upload experience leaves the user with:
- Trust that what they meant to store is stored. For manual uploads, the confirmation is explicit enough that they will delete the on-device copy without anxiety. For backup, they have a standing, low-effort way to confirm the mechanism is keeping up.
- A private result. They know the content is visible only to them — no third party, including the operator, can see it (see Constraints Inherited — Private by default).
- Freedom from the old provider’s anxieties. No quota warning, no “you’re running out of space, upgrade now.” The system quietly absorbs whatever they throw at it.
- For backup specifically: the feeling of not having to think about it. The best version of this experience is invisible — the user takes photos for years and their memories are simply, continuously safe.
Edge Cases & Failure Modes
- Network drops mid-upload. Experience-level handling: the upload is perceived as paused, not failed, and it resumes from where it stopped when connectivity returns — it is not silently abandoned, and it does not restart from zero. The user is never left believing something was stored when it wasn’t.
- The same photo is already backed up. Re-running backup, or a device re-reporting content it already sent, must not pile up duplicates. The user is not punished for caution; their library does not fill with copies of the same moment. (How de-duplication is achieved is an implementation concern, not part of this experience.)
- A very large file or video. There is no quota to bump into, but large items take longer. The experience keeps the user informed (Branch A) or simply handles it in the background over time (Branch B). The user is not forced to sit and watch.
- Device offline for days, then reconnects. Backup catches up on its own. The user who returns from a trip with a full camera roll finds it all backing up without manual intervention.
- The user needs to believe “backed up” before freeing device space. This trust is the crux of the Zero data loss KPI at the moment of upload: users delete on-device copies based on the system’s confirmation. So the experience must never show “backed up” for something not yet durable. If there is any state where an item is in progress versus safely stored, the user can tell the two apart before they rely on it.
- Partial batch upload. If some items in a batch land and others don’t (e.g. connectivity died mid-way), the user can see which succeeded and which still need to complete, rather than getting an all-or-nothing verdict that hides a gap.
- Backup silently stops. Permission gets revoked, the backup process is killed for good, or the device simply stops checking in — and the user, by design not paying attention, keeps taking photos believing they are safe. This is the single most dangerous failure for Zero data loss, so it is the one place the backup experience is deliberately not passive: prolonged silence is treated as suspicious, not successful. When backup has made no progress for longer than an expected check-in window, or when permission is affirmatively revoked, the user is actively notified — a push/alert that reaches them even though they are not looking — in plain language, naming the single corrective action (re-grant permission / re-enable backup). A quiet in-app indicator alone is insufficient here precisely because the endangered user is not opening the app. The alert is calibrated not to cry wolf over a normal overnight or out-of-range gap, but when in doubt it errs toward telling the user, because an unnoticed dead backup is the worst outcome this KPI can suffer.
Constraints Inherited from the Capability
This UX must respect the following items from the parent capability’s Business Rules and Success Criteria — named so future readers can trace the lineage:
- No storage quotas. Users upload freely; there is no per-user limit to plan around and no upsell. Capacity is the operator’s problem, not the user’s. The experience must never confront the user with a quota wall.
- Private by default. Uploaded content is visible only to its owner. No third party — including the operator — can see it. Nothing in the upload flow shares content; sharing is a separate, explicit act (see Share Content).
- Off-site backup is allowed. Durability may be achieved partly by replicating content off-site, provided the off-site copy preserves the same privacy properties. From the user’s seat this is invisible — it simply strengthens the “it won’t be lost” promise behind their confirmation.
- Lost credentials = lost data. Uploaded content is bound to the user’s own account, which only they can access. This is not central to the act of uploading, but it frames what “their storage” means: it is theirs alone, and the same trade-off that protects it (no operator backdoor) is the one covered in depth by Join as an Invited User.
- KPI — Zero data loss. The upload confirmation is the exact point where this KPI is won or lost in daily use: users free device space on the strength of it. “Confirmed stored” must be genuinely durable, and a paused upload must never masquerade as a completed one.
- KPI — Number of active users. Uploading is the canonical active-user signal (the KPI counts {upload, view, download, share} in a trailing 30-day window). Automated device backup is what turns a one-time uploader into a continuously active user, because content keeps arriving without the user having to remember to act — making it the single most important contributor to non-attrition on this KPI.
Out of Scope
- Bulk-importing an existing library from another provider. Moving years of history out of Google Photos / iCloud in one go is a different journey with a different mindset (a one-time migration, verified for completeness before cancelling the old provider). It lives in Bulk Import from a Prior Provider, not here. This doc covers routine, ongoing capture.
- Viewing, searching, and organizing what was uploaded. Once content is stored, browsing and arranging it is View and Organize Content.
- Sharing uploaded content. Making content visible to another user is Share Content.
- The initial decision to enable backup during onboarding. Where and how a new user is first pointed at “turn on backup” is part of Join as an Invited User. This doc assumes an already-provisioned user.
Open Questions
None remaining. The three questions this journey previously carried have been resolved and folded into the sections above:
- Granularity of backup status → aggregate by default, per-item on demand: a plain “all caught up as of
- Backup scope → the automatic path backs up the device’s photos and videos as a single on/off per device; arbitrary non-media files are kept deliberately via the manual path rather than swept up by a per-folder selector (Journey, Branch B, step 1).
- Notification when backup silently stops → the backup experience is deliberately not passive here: prolonged silence or revoked permission triggers an active, plain-language alert that reaches the user even when they are not looking (Edge Cases).
2.1.7 - View and Organize Content
One-line definition: A user browses, finds, and organizes their own media, views and downloads originals, and can pull a complete on-demand export of everything they own.
Parent capability: Self-Hosted Personal Media Storage
Persona
The actor is an active user — a Primary actor from the parent capability’s Stakeholders — spending time in their own library. They have already stored content (via Upload Content or Bulk Import from a Prior Provider) and now want to use it.
- Role: An everyday user browsing, finding, arranging, and retrieving their own photos, videos, and files. Non-technical is the default assumption — they think “my albums,” “that trip,” “the photo of the receipt,” not “queries” or “objects.”
- Context they come from: They are here to relive a memory, find one specific thing, tidy up, or grab an original to use elsewhere. Their reference point is the commercial photo app they left, which set expectations for fast scrolling, a timeline, search, and albums.
- What they care about here: That their library feels like theirs — well-ordered, quick to move through, private — and that they can always get their originals, and their whole archive, back out.
Goal
“I want to look through my own memories — see them by time, find a specific moment, group things into albums — get the original file when I want it, and be able to pull my whole archive out whenever I choose.”
Entry Point
The user opens their own library. The trigger is usually one of:
- Relive / browse — no specific target, just looking through their memories.
- Find one thing — they need a particular photo or file (the receipt, the photo to print, the document to attach somewhere).
- Tidy — they want to arrange things into albums or groups.
- Retrieve — they want the original file to use outside the system.
- Safeguard — they want to pull a complete copy of everything, for their own peace of mind or on a schedule.
Whatever the trigger, they arrive already authenticated as themselves, seeing only their own content.
Journey
- Open the library. The user lands in their own content — and only their own. Someone else’s photos are never mixed in here; content shared with them lives in a separate experience (Receive Shared Content).
- Browse. The user moves through their library in a way that matches how they remember — typically chronologically. Scrolling through years feels responsive rather than laborious.
- Search / filter. When they have something specific in mind, they narrow down to find it. In this journey, search spans only metadata the system already holds without having to “understand” the content — filenames, capture dates and date ranges, and the user’s own organization (album names, favorites). It deliberately does not include content-understanding search (e.g. typing “beach” and matching pixels) in the baseline, because the tiebreaker in the parent capability is Privacy beats convenience: any feature that derives searchable meaning from the content itself is only acceptable if it can do so without ever exposing that content — or the derived index — to the operator or any third party. Until such a mechanism exists (per-user-key or on-device derivation that stays unreadable to the operator), richer content-based search stays out; if it is ever added, Private by default governs it, not the other way around. A search that matches nothing returns a clear empty result, not an error or a dead end.
- Organize. The user groups content into albums — the one organizing primitive this journey commits to, chosen because it maps directly onto how the non-technical persona already thinks (“that trip,” “the kids”). Alongside albums, two lightweight aids ride on metadata the system already has, so they cost the user nothing to learn: favorites (a one-tap “keep this handy” marker) and date/date-range navigation over the existing timeline. Heavier taxonomy — freeform tags, nested collections, place-based search — is deliberately deferred, not designed in: it adds cognitive load for a non-technical user and, in the case of place/content search, reopens the privacy question above, so it waits until a real need in the operator’s circle pulls for it rather than being built speculatively. Crucially, organizing is non-destructive: albums and favorites are ways of seeing their content, not copies of it and not the content itself.
- View a specific item. They open an individual photo, video, or file to look at it closely.
- Download originals. When they want a local copy — to print, to send outside the system, to edit — they retrieve the original file, not a degraded version.
- Pull a complete on-demand export. At any time, the user can request a full archive of everything they own, without operator involvement, while the system is healthy. This is the user-facing home of the capability’s Operator succession export mechanism: users are expected to pull these proactively, and may do so on a schedule, because on-demand export is only available when the system is up. This is also the concrete expression of the Longevity and Control outcomes — their data is always theirs to take. Two things about the archive are settled here in favor of the stronger Longevity guarantee:
- What’s in it: the export contains originals plus the user’s organization, not originals alone. Originals land as first-class, unmodified files; the user’s structure — album membership, favorites, and capture dates — travels alongside them in an open, self-describing, non-proprietary form (a portable manifest/sidecar readable without this system). A takeout that dropped album structure would be a weaker Longevity guarantee — the user would keep their pixels but lose the years of arranging that made the library theirs — so the archive is designed to be understandable, and re-importable, even if this system no longer exists.
- Scheduling and destination: the user can schedule recurring exports themselves, self-service, exactly as the capability’s “may schedule periodic pulls” language intends — no operator step. Because the entire point of a proactive pull is to survive the system being down, a scheduled archive must land somewhere the user controls that is independent of this system’s health (a destination the user designates — their own machine or their own off-system storage). An “export” that only ever wrote back into the same system would defeat its own purpose, so the destination is deliberately outside the system’s fate.
Flow Diagram
flowchart TD
Open([User opens their own library]) --> Own[Sees only their own content]
Own --> Intent{What did they come to do?}
Intent -->|Relive| Browse[Browse chronologically —<br/>feels responsive]
Intent -->|Find one thing| Search[Search / filter]
Intent -->|Tidy| Organize[Group into albums<br/>— non-destructive]
Intent -->|Retrieve| View[View a specific item]
Intent -->|Safeguard| Export
Search --> Found{Match?}
Found -->|No| Empty[Clear empty result]
Found -->|Yes| View
Browse --> View
Organize --> AlbumNote[Deleting an album ≠<br/>deleting the photos]
View --> Download[Download originals]
View --> Export[Pull complete on-demand archive<br/>— only while system is healthy]
Download --> Done((Library feels theirs;<br/>originals & archive always retrievable))
Export --> Done
Empty --> OwnSuccess
A successful view-and-organize experience leaves the user with:
- A library that feels like theirs. Ordered, searchable, and arranged the way they think about their own memories — good enough that they stop missing the commercial app they left.
- What they came for. They found the specific thing, relived the trip, or grabbed the original they needed.
- Standing assurance of control. They know they can always download originals and pull their entire archive out — so the system never feels like a place their data is trapped. This is what makes them comfortable treating it as their primary store.
- No accidental destruction. They tidied without fear, because organizing never risks their content.
Edge Cases & Failure Modes
- A large library. Experience-level handling: browsing years of media feels responsive; the user can leave and come back without losing their place, and no single view forces them to wait on the whole collection loading before they can do anything.
- Organizing must never destroy content. Deleting or emptying an album removes a way of seeing the content — it does not delete the photos in it. The experience keeps this distinction unmistakable, so a user tidying up can never be tricked into destroying originals. (Actual deletion of content is a separate, deliberate act — see Delete Content and Leave.) This guard is a direct defense of the Zero data loss KPI.
- Export only while the system is healthy. The on-demand full archive is available when the system is up. If the system is down, only previously-pulled exports survive — an honest caveat inherited from Operator succession. The experience should make proactive/scheduled pulls feel like the natural habit, not an afterthought, precisely because a user who waits until the system is down has waited too long.
- A search finds nothing. The user sees a clear “nothing matched,” not an error — and can adjust and try again.
- A very large export takes a while. Pulling everything is a big operation; the user perceives it as progressing and can retrieve the finished archive when it is ready, rather than being blocked staring at it.
- A scheduled export’s destination is unreachable. Because a scheduled recurring export targets a user-controlled destination outside this system, that destination can be full, offline, or revoked when a run fires. The experience treats a failed scheduled pull as legibly failed, not silently skipped — the user learns their safety habit didn’t run, so a lapsed backup never masquerades as a healthy one. This honesty is what keeps scheduled export a real hedge rather than a false sense of security.
- Downloading a huge original or batch. Retrieving originals — especially many at once or a large video — is legible and resumable in spirit (the user is not left guessing whether a stalled download failed), consistent with how uploads behave in Upload Content.
Constraints Inherited from the Capability
This UX must respect the following items from the parent capability’s Business Rules and Success Criteria — named so future readers can trace the lineage:
- Private by default. The user browses only their own content, and no third party — including the operator — can browse it. There is no operator “view all libraries” surface. Content becomes visible to anyone else only through the explicit, separate act of Share Content. This is the rule that keeps content-understanding search out of the baseline (Journey, step 3): any search index derived from the content itself would be a new surface on which the operator could read content, so it is not built unless it can be derived and stored in a form the operator cannot read.
- Operator succession. The on-demand full export lives in this journey. The capability requires that every user can pull a complete archive of their own content without operator involvement while the system is healthy — this UX is where that promise is exercised, including the “only while healthy” caveat and the expectation that users pull proactively. The capability’s “may schedule periodic pulls” language is resolved here as user-scheduled, self-service recurring exports whose archive lands in a user-controlled destination outside this system (Journey, step 7), so a scheduled pull actually survives the system going down.
- No storage quotas. Nothing in browsing or organizing pushes the user toward pruning to save space; they organize for their own sake, not to stay under a limit.
- Off-site backup is allowed. Invisible here, but it is part of why the originals the user retrieves are trustworthy and durable.
- Lost credentials = lost data. The on-demand export is the user’s hedge against loss, which is why the journey urges pulling it proactively — but the experience must stay honest that it is not a recovery mechanism: a user who loses their own credentials loses access to the library itself, exports included, with no operator backdoor. Export protects against the system failing; it does not protect against the user losing their key.
- KPI — Zero data loss. The non-destructive-organizing guarantee is this journey’s contribution to the KPI: tidying, re-arranging, and deleting albums must never cost the user actual content.
- KPI — Number of active users. Viewing, downloading, and organizing are three of the four counted active-user actions (the fourth being sharing). A user who comfortably lives in their library is, by definition, an active — and retained — user. A library that feels slow or untrustworthy would show up as attrition on this KPI.
Out of Scope
- Getting content in. Uploading and device backup are Upload Content; the one-time migration of a prior library is Bulk Import from a Prior Provider.
- Sharing. Selecting content to share starts from the library, but the act of granting someone access — choosing a recipient or a shared group, and revoking later — is its own journey, Share Content.
- Viewing content shared with the user. Content others shared with them is not part of their own library; it is Receive Shared Content.
- Deleting content and leaving. Deletion is initiated from the library too, but its safety net (30-day retention) and the departure journey are Delete Content and Leave.
Open Questions
None remaining. The four questions this journey previously carried have been resolved and folded into the sections above:
- Organizing primitives → the journey commits to albums as the single organizing primitive (it matches how the non-technical persona already thinks), plus two zero-learning aids that ride on metadata the system already holds — favorites and date/date-range navigation. Heavier taxonomy (freeform tags, nested collections, place-based search) is deliberately deferred until a real need pulls for it, rather than designed in speculatively (Journey, step 4).
- What search spans → only metadata the system already holds without “understanding” the content — filenames, capture dates/ranges, album names, favorites. Content-understanding search (e.g. “beach”) is kept out of the baseline under the Privacy beats convenience tiebreaker, and may be added later only if it can be derived and stored in a form the operator cannot read — Private by default governs it, not the reverse (Journey, step 3 and Constraints).
- What a “complete export” contains, and in what form → the stronger Longevity guarantee: originals plus the user’s organization (album membership, favorites, capture dates) in an open, self-describing, non-proprietary form readable and re-importable without this system — not originals alone (Journey, step 7).
- User-scheduled recurring exports and their destination → yes, self-service, with the archive landing in a user-controlled destination outside this system so a scheduled pull actually survives the system going down; a failed scheduled run fails legibly rather than silently (Journey, step 7, Edge Cases, and Constraints).