Skip to content

Linked household accounts and PCS-aware family job comparison (technical spike)

Status: proposed architecture; not implemented Ticket: WING-463 Decision owner: Andrew Jones-Gaffney Decision review: after the discovery and prototype gates in Evidence plan Plain-language version: ../plain/linked-household-accounts.md

Decision

Proceed to a consent prototype, not production implementation yet.

The architecture is feasible if WorkWingman treats a household as a coordination space between independent adult accounts, not as a shared account or a replacement for a person's private workspace. Each adult keeps their own profile, searches, applications, documents, messages, credentials, and eligibility data. A household comparison receives only the scenario-specific facts that each adult deliberately publishes to it.

The first use case is a military permanent-change-of-station (PCS) move: the service member may keep their current role while a spouse and adult children decide whether they are moving, search around the destination, and add selected jobs or offers to a household plan. The same model supports civilian couples and adult family members relocating together.

The production decision stays conditional because official sources establish the workflow and privacy constraints, but do not prove that households want to link accounts or share enough data for the comparison to be useful.

Architecture decision in one sentence

Private personal workspaces publish revocable, purpose-limited projections into a separate household coordination realm; they never grant the household direct access to personal records.

flowchart LR
    A["Adult A private workspace<br/>profile, jobs, offers, documents"]
    B["Adult B private workspace<br/>profile, jobs, offers, documents"]
    C["Adult child private workspace<br/>profile, jobs, offers, documents"]
    P1["A-owned projection"]
    P2["B-owned projection"]
    P3["Adult-child-owned projection"]
    H["Household coordination realm<br/>membership, move scenarios, projections, plans"]
    R["Household comparison<br/>visible rows, ranges, unknowns, hard constraints"]

    A -->|"explicit publish"| P1 --> H
    B -->|"explicit publish"| P2 --> H
    C -->|"explicit publish"| P3 --> H
    H --> R
    H -. "cannot read private records" .-> A
    H -. "cannot read private records" .-> B
    H -. "cannot read private records" .-> C

Why the current model cannot simply be extended

Current seam What it provides Why it is insufficient
TenantSessionService and ITenantDocumentStore A signed-in member gets a private, tenant-scoped workspace. Tenant scope is an authorization boundary. A household is many independently authorized people. Reusing one tenant would violate the existing rule that invitations must not silently merge mutable user state.
FamilyProfile / FamilyController Optional adult and child counts plus grocery, school, and city cost assumptions. It has no people, accounts, employment, invitations, consent, ownership, or tenant scope. Existing adult counts must never be treated as identities.
JobSearchCriteria, JobSearchService, and job search UI One user's keyword/location search and explicit save flow. There is no member owner, relocation scenario, or household projection. One adult must never run or apply from another adult's profile.
compare-net, LocationComparison, and offer evaluation Evidence-aware location/tax previews and one person's job/offer tradeoffs. Household comparison needs several employment states, tax units, shared costs, timing, and non-disclosing ranges. Existing hard rules also prohibit an opaque composite winner score.
Veteran and USAJOBS models Military-spouse hiring-path metadata on a posting. Posting metadata does not establish a person's eligibility and must not be inherited by a household, used as a ranking oracle, or exposed to employers.

Personal FamilyProfile remains single-user only. Household plans use separately proposed, typed HouseholdCostAssumptions and composition that every affected adult confirms; they never read or copy any member's FamilyProfile. No migration may turn Adults = 3 into three members or infer relationships from a headcount.

Product and safety rules

  1. Every linked adult has an independent account and private workspace. MVP linked accounts are 18+; minors remain optional cost assumptions, not account members. Invite creation and redemption fail closed unless the account has a server-verified adult-eligibility claim; self-declared age is insufficient.
  2. Creating an invitation shares no candidate data. Accepting creates a pending admission, not membership; current adults see only its opaque admission id, a validated candidate-chosen display claim, a coarse server-derived account-tenure band, and adult-eligibility-attested status so they can confirm the right account. The identity attestation is candidate-consented and hash-bound; raw email, phone, legal/IdP name, photo, provider ids, contact value, and global subject ids are forbidden. Only after all-current- adult confirmation does the activated member receive a fresh MembershipId and optional validated household label. Participation in a relocation scenario and each shared field require separate consent.
  3. A relocation event fans out into private task prompts only for members whose confirmation matches the current scenario hash. The sole pre-confirmation exception is a payload-free candidacy-offer prompt to its designated target; it carries only an opaque prompt id and authorization epoch and grants no scenario read. Email, push, in-app, and OS-local notification queues otherwise store only those two values; display-time resolution rechecks live authorization and drops mismatches. Email, push, and OS surfaces use a closed generic template such as “Household task waiting” and contain no member label, geography, window, money, employer, employment state, scenario lifecycle, or reason. Sensitive detail resolves only in-app after reauthentication. It never issues or reuses a HouseholdInvitation and never inserts a personal record for another subject. It never lets the relocating member search, edit, apply, or answer questions for another adult.
  4. A member may request leave without approval. An offline request stays pending only on that member's devices; after server acknowledgement, leaving deletes their household projections and prevents future access while preserving their private account and records. Exit is never blocked to preserve a coordinator role: a sole remaining peer may self-elect, while multiple remaining adults must all confirm an election. Otherwise the realm becomes unmanaged. Coordinator verbs and all peer projection, plan, comparison, share, and export reads pause; self views, leave, owner policy reduce/revoke, owner projection delete, self scenario removal, and silent blocks remain always-on. Recovery never restores peer reads until each resource owner explicitly grants the new audience. Only a realm with no remaining members is archived and purged. In any household size, roster or per-member row changes may reveal who left or reduced sharing; that unavoidable state-channel risk is disclosed before first share. Current participants and a candidate also receive the same warning before a participant-expansion preview: generic readiness or a missing confirmation can reveal that someone has not proceeded even though identity, decline, and timestamps remain hidden. The preview says that expansion grants only the displayed normalized scenario facts; prior job, pay, benefit, timing, and constraint projections remain invisible until each owner reviews the current audience, field categories, and precision and grants that audience explicitly.
  5. Household coordinators may manage the household name and invitations, but cannot edit another adult's policies, projections, or private data.
  6. Exact PCS orders, addresses, travel itineraries, report dates, documents, credentials, resumes, application answers, messages, protected characteristics, and military eligibility remain private by default and are outside the household store.
  7. Unknown is never zero. Ranges remain ranges through every calculation. Authorized values may be typed unknown or omitted; peers see only a generic incomplete state, while self-only diagnostics may name the caller's own missing action or a non-personal system capability.
  8. WorkWingman shows tradeoffs and hard constraints. It does not rank family members, choose whose career matters more, or produce a household “best move” score.
  9. Military-spouse status is person-scoped, self-declared, and private. It may activate a private resource or USAJOBS checklist; it never becomes employer-visible telemetry or a general fit axis.

PCS and trailing-spouse workflow

  1. Create a move scenario. A member enters a destination region or installation, CONUS/OCONUS, and a report or arrival window. Exact orders and dates are not required. The draft is creator- private until its visibility and precision are deliberately shared with accepted participants. The scenario moves through Exploring -> OrdersReceived -> Committed -> Arrived | Cancelled | Archived.
  2. Invite adults. Each person receives a single-use, expiring, identity-bound invitation. The inviter cannot pre-accept or preconfigure the invitee's sharing.
  3. Stage scenario participation. A current participant sends a payload-free target-only candidacy offer. The target privately accepts candidacy; current participants confirm the expansion; the target receives a short-lived normalized preview and confirms that exact hash or silently declines. Household acceptance alone never exposes the scenario.
  4. Choose individual move intent and search. Only after participation commits, each adult privately records Moving, Staying, Undecided, or NotParticipating and a closed EmploymentState. For a moving member, WorkWingman proposes the destination, radius, start window, and remote/hybrid constraints. That member edits and activates it in their own workspace. Address mutation is not required, so search can begin before the move.
  5. Search privately. Jobs, resumes, eligibility, application activity, and messages stay personal. The household sees only a member-authored status such as “search active” unless more is shared.
  6. Publish selected options. A member can publish a job or offer projection with a chosen financial disclosure: unknown, range, or exact. Publishing a job never shares the underlying application.
  7. Compose plans. Members select one employment state or published option per participating adult. Examples are “move while spouse searches,” “spouse offer A,” and “adult child stays in current city.”
  8. Compare the whole picture. WorkWingman shows income bounds, tax units, shared living costs, benefits coverage, relocation aid/costs, employment gaps, commute/childcare, and hard constraints over time. It does not select a winner.

Military-specific facts that shape the model

  • Destination searching may begin before a household address changes. Military OneSource describes spouse job-search support at the current location, destination, and remote, and notes that some PCS- linked preference activity can begin before the sponsor's report date.
  • Military-spouse federal hiring mechanisms are distinct. USAJOBS hiring paths, OPM noncompetitive authority, and agency/DoD preferences must not be collapsed into one IsMilitaryFamily flag.
  • Professional licensure may determine whether a job is feasible. License state, profession, compact or portability path, time, cost, and reimbursement are scenario evidence, not hidden fit weights.
  • “Remote” must distinguish domestic remote, overseas eligible, DETO approval required, and unknown. OCONUS employment can involve SOFA, tax, agency, security, and approval constraints.
  • An installation, metro, commute radius, and candidate residence are different geography concepts. The search plan should not reduce all four to one free-text city.
  • Official resources such as SpouseWorks are handoffs the product should surface and track; linked comparison is not a substitute for employment coaching, licensing support, or employer programs.

Proposed domain model

The C# below defines boundaries and ownership, not final persistence shapes.

public sealed record Household(
    HouseholdId Id,
    int Version,
    HouseholdAuthorizationEpoch AuthorizationEpoch,
    string DisplayName,
    ResourceCreator CreatedBy,
    DateTimeOffset CreatedAt,
    HouseholdStatus Status);

public readonly record struct HouseholdAuthorizationEpoch(long Value);
public readonly record struct ScenarioAudienceGeneration(long Value);
public readonly record struct ScenarioAudienceSnapshotId(Guid Value);

public enum HouseholdPermission
{
    ManageHousehold,
    ManageInvitations,
    EditScenario,
    EditPlan,
    ManagePlans
}

public sealed record HouseholdMembership(
    MembershipId Id,
    HouseholdId HouseholdId,
    int Version,
    MembershipPrincipal Principal,
    MembershipStatus Status,
    IReadOnlySet<HouseholdPermission> Permissions,
    string? MemberChosenLabel,
    DateTimeOffset JoinedAt,
    DateTimeOffset? LeftAt);

public sealed record MembershipRef(MembershipId Id);

public abstract record ResourceCreator;
public sealed record ActiveResourceCreator(MembershipRef Membership) : ResourceCreator;
public sealed record DepartedResourceCreatorTombstone(string OpaqueTombstoneId) : ResourceCreator;

public sealed record HouseholdMemberDisplay(MembershipId Id, string? MemberChosenLabel);

// MembershipId is freshly and uniquely minted for each join lifecycle. A rejoin never reuses an id,
// so old grants and foreign keys cannot attach to the new membership.

public abstract record MembershipPrincipal;

public sealed record ActiveMembershipPrincipal(string OpaquePrincipalRef) : MembershipPrincipal;

public sealed record DepartedMembershipTombstone(string OpaqueTombstoneId) : MembershipPrincipal;

public sealed record HouseholdInvitation(
    InvitationId Id,
    HouseholdId HouseholdId,
    MembershipRef InvitedBy,
    string DisplayCode,
    int Version,
    InvitationBinding Binding,
    InvitationThrottleLeaseId ThrottleLeaseId,
    string TokenHmac,
    DateTimeOffset CreatedAt,
    DateTimeOffset ExpiresAt,
    InvitationStatus Status,
    AdmissionId? AcceptedAdmission,
    DateTimeOffset? AcceptedAt,
    MembershipRef? CancelledBy,
    DateTimeOffset? CancelledAt,
    DateTimeOffset? DeclinedAt);

[SensitiveData]
public sealed record InvitationThrottleLease(
    InvitationThrottleLeaseId Id,
    HouseholdId HouseholdId,
    string TargetThrottleHmac,
    DateTimeOffset NominalExpiresAt);

public enum InvitationStatus
{
    Sent,
    AdmissionPending,
    Activated,
    DeclinedInternal,
    Cancelled,
    Expired
}

public abstract record InvitationBinding;

public sealed record ExistingSubjectBinding(string AccountSubjectHmac)
    : InvitationBinding;

public sealed record NonRedeemableInvitationShadow(string OpaqueShadowId)
    : InvitationBinding;

public sealed record ScrubbedInvitationTombstone(string OpaqueTombstoneId)
    : InvitationBinding;

[SensitiveData]
public sealed record PendingHouseholdAdmission(
    AdmissionId Id,
    HouseholdId HouseholdId,
    InvitationId InvitationId,
    string OpaqueCandidateRef,
    CandidateAdmissionAttestation Attestation,
    int Version,
    string ActiveMembershipSetHash,
    string ContentHash,
    IReadOnlyDictionary<MembershipRef, string> Confirmations,
    DateTimeOffset ExpiresAt);

public sealed record CandidateAdmissionAttestation(
    string ValidatedCandidateDisplayClaim,
    AccountTenureBand AccountTenure,
    string AdultEligibilityFingerprint,
    int EmptyGrantSchemaVersion,
    string IdentityAuthoritySignature);

public sealed record AdultEligibilityClaim(
    AdultEligibilityIssuer Issuer,
    AdultEligibilityMethod Method,
    DateTimeOffset VerifiedAt,
    DateTimeOffset ExpiresAt);

public sealed record SharingPolicy(
    HouseholdId HouseholdId,
    ScenarioId ScenarioId,
    MembershipRef OwnerMembership,
    SharingPolicyKind Kind,
    ProjectionId? ProjectionScope,
    ScenarioAudienceSnapshotId AudienceSnapshotId,
    ScenarioAudienceGeneration AudienceGeneration,
    int Version,
    IReadOnlySet<SharedField> Fields,
    GeographyPrecision? GeographyPrecision,
    TimePrecision? TimePrecision,
    FinancialDisclosure CompensationDisclosure,
    FinancialDisclosure ConstraintMoneyDisclosure,
    FinancialDisclosure LicensureCostDisclosure,
    HoursDisclosure HoursDisclosure,
    DurationDisclosure DurationDisclosure,
    DateTimeOffset GrantedAt,
    DateTimeOffset? RevokedAt);

public enum ScenarioField { Reason, Lifecycle, ConusContext }

public enum SharedField
{
    MoveParticipation,
    EmploymentState,
    SearchStatus,
    SearchGeography,
    SearchRadius,
    MoveAvailability,
    SearchTiming,
    TargetRoleSummary,
    MemberSummary,
    WorkArrangement,
    RemoteWorkContext,
    SelectedJobLabel,
    SelectedRoleSummary,
    SelectedEmployerSummary,
    WorkRegion,
    Compensation,
    ExpectedHours,
    BenefitsSummary,
    EmploymentTiming,
    JobConstraints,
    PublicSources,
    LicensureFeasibility,
    LicensureProfessionCategory,
    LicensureCost,
    LicensureDuration
}

public enum FinancialDisclosure { None, Range, Exact }
public enum HoursDisclosure { None, Range, Exact }
public enum DurationDisclosure { None, Range, Exact }
public enum SharingPolicyKind { Coordination, EmploymentOption }

ProjectionScope is the household output ProjectionId, not a personal-record id. The authenticated owner selects a typed private source only inside a /api/me publish command; the personal application layer loads that owner-scoped record and passes its value to the projection factory. The factory atomically creates the normalized household projection and its item-scoped policy under a new ProjectionId. Neither the private source id nor a capability to retrieve it crosses into the household realm. Coordination policies require a null ProjectionScope; their frozen allowlist is MoveParticipation, EmploymentState, SearchStatus, SearchGeography, SearchRadius, MoveAvailability, SearchTiming, TargetRoleSummary, MemberSummary, WorkArrangement, RemoteWorkContext, LicensureFeasibility, LicensureProfessionCategory, LicensureCost, and LicensureDuration. EmploymentOption policies require the target output ProjectionId; their frozen allowlist is SelectedJobLabel, SelectedRoleSummary, SelectedEmployerSummary, WorkRegion, WorkArrangement, RemoteWorkContext, Compensation, ExpectedHours, BenefitsSummary, EmploymentTiming, JobConstraints, and PublicSources. Job-level license blockers use only closed constraint codes. Scenario facts are governed only by ScenarioVisibility and cannot appear in a SharingPolicy. The server rejects a field outside the selected policy kind's allowlist; an intentionally shared enum value such as WorkArrangement is still interpreted only through that kind's projection schema.

CompensationDisclosure governs only Compensation; ConstraintMoneyDisclosure governs only money- valued JobConstraints; LicensureCostDisclosure governs only LicensureCost; HoursDisclosure governs ExpectedHours; and DurationDisclosure governs LicensureDuration. Each disclosure must be None when its corresponding field is absent; a present field plus None emits its typed unknown variant; Range or Exact without the field is invalid. These precisions are independent. The closed geography sets are Coordination { SearchGeography } and EmploymentOption { WorkRegion }; SearchRadius alone does not set a geography precision. The closed timing sets are Coordination { MoveAvailability, SearchTiming } and EmploymentOption { EmploymentTiming }. GeographyPrecision or TimePrecision is non-null exactly when Fields intersects its corresponding set; all other combinations are rejected.

ExistingSubjectBinding.AccountSubjectHmac is a server-keyed, domain-separated HMAC over the canonical binary encoding of WorkWingman's stable internal SubjectId, never an email hash or provider login id. It prevents an invitation forwarded to a different existing account from being redeemed. MVP invitation creation may resolve a verified contact to an already-existing account server-side, but the response is constant-shape when no account exists; pre-account redemption is production-disabled until a separately reviewed claim flow can prove safe handling of shared mailboxes and compromised contacts. Missing, blocked, and otherwise non-receivable targets create NonRedeemableInvitationShadow rows with the same inviter-visible Sent -> Expired|Cancelled lifecycle and no contact or dummy subject. Every real and shadow outcome binds to a separate identity-authority InvitationThrottleLease carrying the same domain-separated TargetThrottleHmac over the canonical contact. It is a redacting, non-reversible abuse/uniqueness key, never a redemption identity or household field. The lease survives until nominal ExpiresAt even after internal decline or inviter cancellation; those actions stop redemption/delivery but never reopen the inviter's slot early. Storage enforces the same lease behavior for account and shadow targets. When contact resolution identifies an existing subject, it also enforces one open invite per (HouseholdId, SubjectId) across alternate contact forms; inviter responses never reveal whether creation deduplicated into a real row, alternate-contact row, or shadow.

Acceptance creates a PendingHouseholdAdmission, not active membership. The identity authority retains the single-use candidate mapping; the household realm stores only OpaqueCandidateRef, which cannot be resolved by IHouseholdStore, exports, support tools, backups, or logs. Every adult active at acceptance must confirm the admission's exact hash. The domain-separated canonical hash covers HouseholdId, InvitationId, OpaqueCandidateRef, the complete signed CandidateAdmissionAttestation, ActiveMembershipSetHash, admission version, empty-grant schema version, and ExpiresAt; any change mints a new version and clears confirmations. Candidate display text passes the same fail-closed legal-name, contact, employer, money, location, timing, URL, credential, and identifier validator as shared labels. AdultEligibilityFingerprint is an opaque, domain-separated identity-authority attestation fingerprint; its value cannot identify the issuer, verification method, credential, or underlying evidence to a household reader.

MVP identity authority enforces one global reservation per subject across { active membership, pending admission }; accepting another household invitation or creating a household while reserved returns the same generic result. Final activation runs inside one transactional membership authority—not a distributed best-effort call—which atomically rechecks the admission/quorum/hash, single-use candidate mapping, adult eligibility, global reservation, and household uniqueness; mints the fresh membership with an empty HouseholdPermission set; updates the subject index; marks the invitation Activated; and invalidates the candidate mapping. Idempotency by AdmissionId returns the same completed result. The candidate may withdraw without a peer-visible event; membership-set changes mint a new admission version and clear confirmations. Candidate withdrawal immediately releases the subject's global pending-admission reservation but leaves the invitation throttle lease consumed through its nominal expiry, so withdrawal cannot be used to probe account state or reopen inviter capacity. Withdraw, expiry, invalidation, or activation irreversibly invalidates the candidate mapping. An admission sweeper has the same bounded-lag, row-lock, retry, and activate-vs-expire race contract as the invitation sweeper. No pending admission enters deletion, election, challenge, scenario, or plan quorums.

Acceptance immediately replaces the redemption binding with ScrubbedInvitationTombstone, records AcceptedAdmission, and moves to AdmissionPending; it persists no subject binding or token HMAC. The closed internal lifecycle is Sent -> AdmissionPending -> Activated, Sent|AdmissionPending -> DeclinedInternal, Sent -> Cancelled, and any non-activated state to Expired. An inviter cannot cancel another adult's already accepted admission. The inviter projection maps Sent, AdmissionPending, and DeclinedInternal to Sent; it exposes only Cancelled, Expired, or the eventual activated membership through the member list. The raw token and raw contact value are never persisted. Activation deletes the throttle lease because active-membership uniqueness now blocks reissue; expiry deletes it after the nominal window. Issue/redeem/cancel/decline/expire/activate transitions are atomic, single-use, rate-limited, and audited with generic mismatch responses. Contact-to-account resolution and abuse-ledger target keys use a separate versioned, claim-type-specific canonicalizer. Email domains are IDNA/case normalized; local parts are not dot-folded or case-folded unless the verifying provider explicitly guarantees that equivalence. Unknown or changed equivalence fails closed. Property tests cover case, Unicode, IDNA, dot variants, and non-equivalent provider forms. Redemption never applies contact canonicalization; it HMACs the authenticated session's SubjectId and compares that value to ExistingSubjectBinding. AdultEligibilityClaim is personal identity-service state, never household data. Its issuer and method come from a closed trusted catalog, its maximum age and expiry are enforced at invite creation and redemption, and self-declared age is never sufficient. Missing, unknown, stale, or revoked evidence fails closed with the same generic response. Adult eligibility, stable subject transport, and the single transactional identity/membership authority are phase-0 hard prerequisites.

Expiration is active, not merely inferred from ExpiresAt: every invitation read or redemption lazily transitions an overdue pending row to Expired and scrubs its binding, while an idempotent scheduled sweeper does the same for unread invitations. Sweeper lag is monitored and bounded; its transaction locks the row and competes safely with accept, revoke, and decline. DisplayCode is a short server-generated random label unrelated to the contact value, token, or identity. Pending-invitation UI uses that code plus creation time and status so coordinators can distinguish invites without retaining or revealing the destination contact. Invitation, admission, and create/accept DTOs contain no target permission set. Activation always assigns the server-versioned frozen empty MVP join grant, so a stale row, client field, or datastore value cannot elevate an invitee. Any later elevation is a separate authorized action with its own audit event. Decline remains invisible and never releases inviter capacity before nominal expiry. Storage permits at most one non-terminal admission or active membership per (HouseholdId, SubjectId) and one live throttle lease per (HouseholdId, TargetThrottleHmac); parallel accept/activate attempts produce one result and generic terminal responses for the rest. Until multi-household isolation is separately designed, the global reservation above permits no second active household or pending admission for the subject.

Only ActiveMembershipPrincipal participates in household authorization. OpaquePrincipalRef is a fresh, domain-separated, household-pairwise value minted and resolved only by the transactional identity/membership authority; it is not a global subject id, contact-derived hash, provider id, or value that IHouseholdStore can reverse. The sole pre-membership exception is the identity-bound candidate's /api/me/household-admissions minimal self read/withdraw capability; it grants no household principal and every household-scoped route remains active-member-only. On exit, the subject-to-membership index is deleted and the realm record is replaced by DepartedMembershipTombstone; historical payload-free events may retain the membership id but not the global subject id. Invitations are already identity-free because every terminal transition scrubs their bindings; exit reasserts that invariant transactionally. Every household authorization decision reloads active membership or uses a server-issued cached decision tagged with the exact current AuthorizationEpoch; equality is required and a client-supplied epoch is never trusted. Every transition in the canonical authorization-epoch list under Lifecycle and deletion bumps the epoch before acknowledgement.

[SensitiveData]
public sealed record SensitiveValue<T>(T Value);

public sealed record PrivateLicenseTransferEvidence(
    ProfessionId Profession,
    JurisdictionId DestinationJurisdiction,
    LicensePathCode? Path,
    DurationEvidence? EstimatedDuration,
    MoneyEvidence? EstimatedCost,
    bool? ReimbursementExpected,
    LicenseFeasibility Feasibility);

public sealed record SharedLicenseTransferEvidence(
    ProfessionCategoryId? Profession,
    LicenseFeasibility? Feasibility,
    DurationEvidence? Duration,
    MoneyEvidence? Cost);

public abstract record DurationEvidence;
public sealed record UnknownDuration() : DurationEvidence;
public sealed record DurationRange(TimeSpan Low, TimeSpan High) : DurationEvidence;
public sealed record ExactDuration(TimeSpan Value) : DurationEvidence;

public sealed record SharedRemoteWorkContext(
    RemoteWorkModeCode Mode,
    RemoteJurisdictionContextCode JurisdictionContext,
    RemoteApprovalStateCode ApprovalState);

public sealed record RemoteWorkContext(
    RemoteWorkModeCode Mode,
    RemoteJurisdictionContextCode JurisdictionContext,
    RemoteApprovalStateCode ApprovalState,
    SensitiveValue<string>? EmployerPolicyNotes);

// Missing private EstimatedCost/EstimatedDuration normalize to UnknownMoney/UnknownDuration. Shared
// evidence is therefore a required disclosure-state union, never a fabricated zero.

[SensitiveData]
public sealed record PrivateRelocationDraft(
    PrivateDraftId Id,
    ScenarioId? PublishedScenarioId,
    TenantId PersonalTenantId,
    SubjectId Owner,
    int Version,
    SensitiveValue<MoveReason> Reason,
    SensitiveValue<MoveLifecycle> Lifecycle,
    SensitiveValue<DestinationRegion> Destination,
    SensitiveValue<DateRange>? ReportOrArrivalWindow,
    SensitiveValue<ConusContext> ConusContext,
    DateTimeOffset UpdatedAt);

[SensitiveData]
public sealed record RelocationScenario(
    ScenarioId Id,
    HouseholdId HouseholdId,
    ResourceCreator CreatedBy,
    int Version,
    MoveReason? Reason,
    MoveLifecycle? Lifecycle,
    PublishedScenarioGeography? Geography,
    PublishedScenarioWindow? Window,
    ScenarioVisibility Visibility,
    ConusContext? ConusContext,
    DateTimeOffset UpdatedAt);

[SensitiveData]
public sealed record PendingScenarioMutation(
    ScenarioId ScenarioId,
    int BaseVersion,
    int ProposedVersion,
    MoveReason? Reason,
    MoveLifecycle? Lifecycle,
    PublishedScenarioGeography? Geography,
    PublishedScenarioWindow? Window,
    ScenarioVisibility ProposedVisibility,
    ConusContext? ConusContext,
    DateTimeOffset ExpiresAt);

public sealed record PublishedScenarioGeography(
    GeographyPrecision Precision,
    QuantizedGeographyValue Value);

public sealed record PublishedScenarioWindow(TimePrecision Precision, DateRange QuantizedRange);

public sealed record SearchRadiusBucket(RadiusBucketCode Code, decimal DisplayMiles);

public sealed record ScenarioVisibility(
    int Version,
    ScenarioAudienceGeneration AudienceGeneration,
    IReadOnlySet<MembershipRef> Participants,
    IReadOnlySet<ScenarioField> GrantedScenarioFields,
    GeographyPrecision GrantedGeographyPrecision,
    TimePrecision GrantedTimePrecision,
    string ContentHash,
    IReadOnlyDictionary<MembershipRef, string> Confirmations);

public sealed record ScenarioAudienceSnapshot(
    ScenarioAudienceSnapshotId Id,
    ScenarioId ScenarioId,
    ScenarioAudienceGeneration Generation,
    string ParticipantSetHash,
    IReadOnlySet<MembershipRef> Participants);

[SensitiveData]
public sealed record PrivateMemberMoveIntent(
    PrivateMoveIntentId Id,
    ScenarioId TargetScenarioId,
    TenantId PersonalTenantId,
    SubjectId Owner,
    int Version,
    SensitiveValue<MoveParticipation> Participation,
    SensitiveValue<EmploymentState> EmploymentState,
    SensitiveValue<DateRange>? AvailableToStart,
    SensitiveValue<PrivateLicenseTransferEvidence>? LicenseTransfer,
    SensitiveValue<RemoteWorkContext> RemoteWorkContext,
    DateTimeOffset UpdatedAt);

[SensitiveData]
public sealed record PrivateMemberSearchPlan(
    PrivateSearchPlanId Id,
    ScenarioId TargetScenarioId,
    TenantId PersonalTenantId,
    SubjectId Owner,
    int Version,
    SensitiveValue<SearchPlanStatus> Status,
    SensitiveValue<IReadOnlyList<string>> TargetRoleSummaries,
    SensitiveValue<string>? HouseholdDisplaySummary,
    SensitiveValue<DestinationRegion> Destination,
    SensitiveValue<decimal>? RadiusMiles,
    SensitiveValue<RadiusBucketCode>? SelectedPublishedRadiusBucket,
    SensitiveValue<IReadOnlySet<WorkArrangement>> WorkArrangements,
    SensitiveValue<DateRange>? StartWindow,
    DateTimeOffset UpdatedAt);

[SensitiveData]
public sealed record MemberCoordinationProjection(
    ProjectionId Id,
    HouseholdId HouseholdId,
    ScenarioId ScenarioId,
    MembershipRef OwnerMembership,
    int Revision,
    ScenarioAudienceSnapshotId AudienceSnapshotId,
    ScenarioAudienceGeneration AudienceGeneration,
    string SourceRevisionDigest,
    int SharingPolicyVersion,
    IReadOnlySet<SharedField> EffectiveFields,
    string EffectiveFieldMaskHash,
    MoveParticipation? Participation,
    EmploymentState? EmploymentState,
    SearchPlanStatus? SearchStatus,
    PublishedScenarioGeography? SearchGeography,
    SearchRadiusBucket? SearchRadius,
    PublishedScenarioWindow? MoveAvailabilityWindow,
    PublishedScenarioWindow? SearchStartWindow,
    SharedLicenseTransferEvidence? LicenseTransfer,
    SharedRemoteWorkContext? RemoteWorkContext,
    IReadOnlySet<WorkArrangement>? WorkArrangements,
    IReadOnlyList<string>? TargetRoleSummaries,
    string? MemberChosenSummary,
    DateTimeOffset PublishedAt);

PendingScenarioMutation has exactly one proposed hash source: ProposedVisibility.ContentHash. A sibling or client-supplied mutation hash is forbidden.

The exact relocation draft, full member intent, and search plan above stay in personal workspaces. PrivateMemberMoveIntent and PrivateMemberSearchPlan are scenario-bound by construction, so their TargetScenarioId is non-null; an unlinked idea remains a separate private relocation draft. PrivateDraftId never crosses that boundary; the personal draft and private employment source may retain only their outbound public PublishedScenarioId/ProjectionId after publication so the owner can republish a still-live output or revoke it without making the private id discoverable from the household realm. A post-revocation publish always receives a new globally unique output id; a revoked ProjectionId is never reused. A server-side scenario publisher first quantizes the destination and window to the granted geography and time precision; RelocationScenario contains only those already-normalized values and is never a serialization path to the exact draft. ScenarioVisibility.Granted*Precision records each scenario's consented maximum disclosure, while the PublishedScenario* values record the actual normalized output; the output may be coarser but never finer. Relocation-draft publish updates only RelocationScenario. Move-intent and search-plan publish commands each load the authenticated owner's current personal intent and search plan for that public scenario, apply the one live coordination policy, and atomically replace the single MemberCoordinationProjection for membership+scenario. Missing sibling input emits only the corresponding unknown/absent fields. Optimistic concurrency uses the projection revision plus a non-reversible SourceRevisionDigest; either publish command therefore converges to the same normalized projection without copying a private id. A separate employment-option factory writes EmploymentOptionProjection; its ProjectionKind distinguishes a selected job from an offer. These are the only two payload-bearing projection types in the household realm. A policy reduction is a synchronous household transaction: it hard-nulls/omits every property outside the reduced mask, recomputes EffectiveFieldMaskHash over the stripped body, strips dependent comparison/cache/export payloads, advances their tombstone/input generations, and only then acknowledges the change. Every household read also checks the stored policy version/mask against the live policy, asserts every non-null property is allowed by EffectiveFields, and denies or invalidates any mismatch; exports, indexes, and cache fills enforce the same invariant. It never expands a stored projection from private source data. Accepted membership alone never returns destination, radius, start window, license status, or remote-work detail. Every participant, policy, and projection is bound to the current membership id; leaving and rejoining never restores an old visibility grant. The field-mask hash proves which normalized policy produced the payload. If a reduction needs private truth that the household realm does not hold, such as Exact to a newly chosen range, the household value becomes UnknownMoney/UnknownHours/UnknownDuration (or the field is dropped) until the owner republishes through /api/me; it is never reconstructed from the exact point value. Geography/time reductions deterministically map to an allowlisted coarser parent or omit the entire property; a finer value is never retained under a coarser precision label. Policy expansion never reveals a newly allowed stored field: the old normalized revision remains the only live peer payload until the owner performs a fresh /api/me publish under the expanded policy. That publish creates a new stripped-and-validated revision atomically; no background job or household read may retrieve private truth to fill the expansion.

Every policy and projection is audience-frozen. SharingPolicy.AudienceSnapshotId is a mandatory foreign key to the exact server-minted ScenarioAudienceSnapshot captured when the owner grants or publishes; generation comparison alone is never authorization. A peer read requires both live current scenario participation and membership in the referenced snapshot. Participant expansion mints a new audience generation but never widens an older policy. The added adult receives only the scenario facts in the preview they confirmed; every projection remains invisible to them until its owner explicitly re-grants the policy to the new current generation and republishes if newly allowed payload is needed. Re-grant shows the owner the current member-chosen labels, field categories, and maximum precisions, and is never bundled with candidate admission. Participant removal cuts access immediately because current participation is still required; old audience snapshots are payload-free and retained only while a live policy references them. Entering unmanaged state mints a new snapshot/generation for every live scenario without changing its participant set. Election clears the unmanaged flag but cannot make an old snapshot current; each owner must explicitly re-grant to the new snapshot before peer reads resume. Every projection must carry the same AudienceSnapshotId and generation as its live policy; mismatch, missing snapshot, or generation-only comparison is a hard authorization failure.

Scenario visibility confirmation is separate from publishing a member's move/search projection: an adult may consent to view and coordinate around the shared destination without publishing their own intent. Participants contains only adults who accepted their own current proposal; pending/declined targets are self-only personal inbox state and never appear in the shared participant set or peer DTOs. A target first accepts candidacy without receiving scenario values. A versioned expansion shell then becomes visible only to current participants, who confirm the proposed target, resulting participant set, field mask, precision, and hash. After every current participant confirms, the target receives one single-issuance, short-TTL, audited preview capability that may be reread during its live window with no-store, showing exactly the normalized values bound to that hash. Target confirmation commits that same hash and adds the participant; decline/expiry scrubs the preview. Current participants are warned that a candidate can retain a preview they saw even if they later decline. Silent decline before candidacy creates no peer-visible state. ScenarioVisibility.ContentHash covers participants, the scenario-field grant, both precision grants, normalized geography/window precision and values, and the normalized Reason, Lifecycle, and ConusContext outputs. Those three values are closed enums, never free text, and are omitted/coarsened unless their field is granted. Confirmations is server-derived from each affected member's dedicated action and maps that member to the exact hash. Reductions (field/precision/participant shrink) commit immediately, irreversibly strip/re-quantize live payload, and may rebind surviving confirmations because disclosure cannot expand. Expansions and same-precision value replacements are staged: the old already-authorized payload remains live, the writer is bound to the proposed post-write hash, every other participant gets a self-only payload-free reconfirmation shell, and store the proposed values only in PendingScenarioMutation; the new payload commits only after all required confirmations, then the pending record is deleted. Scenario DTOs cannot set or infer confirmation for another member. The stored confirmation map is server-internal; peer DTOs expose only the caller's own confirmation state and a generic ready/incomplete status, never a membership roster, counts, or another adult's transition. In small households that generic readiness can still reveal another adult's action; first-share copy treats it as the same accepted residual state channel as roster/member-row changes, and no proactive notification is sent.

QuantizedGeographyValue is a server-produced closed value (for example, an allowlisted region id), never client free text; display copy is rendered from that value. PrivateRelocationDraft is stored only through the named personal tenant's ITenantDocumentStore, whose authorization independently proves the session subject owns that tenant. Every geography/window constructor and API boundary proves that the value belongs to the allowlist for its declared precision; a month precision requires a month-snapped non-null range, and an absent or ungranted value is omitted at the parent rather than represented by null payload inside a present value. SearchRadiusBucket is selected by the owner from a closed server allowlist (for example 10/25/50 miles) and may be stored in the private plan as SelectedPublishedRadiusBucket; it is never inferred by floor/ceil/nearest from the private decimal. Republish uses the stored selection or requires a new explicit selection—it never reconstructs one from RadiusMiles. TargetRoleSummaries is controlled only by SharedField.TargetRoleSummary and passes the same free-text/smuggling validator as every shared label. MemberChosenSummary maps only from HouseholdDisplaySummary, requires SharedField.MemberSummary, and is omitted by default; the same validator applies. The employment-option factory separately gates MemberChosenLabel, RoleSummary, and EmployerSummary with SelectedJobLabel, SelectedRoleSummary, and SelectedEmployerSummary; granting one never emits a sibling. The membership-level MemberChosenLabel is owner-written, optional, and subject to the same validator, but it cannot contain relationship, legal-name, contact, employer, money, or location data.

The prototype defaults to a coarse metro/state region and a calendar-month-or-broader window. A named installation or narrower window requires an explicit creator grant plus participant confirmation and is still excluded from email, push, shared audit, analytics, telemetry, and ordinary request logging. Private destination/window and invitation-binding values use redacting wrapper types; loggers cannot serialize those domain objects. SensitiveValue<T> renders only a redaction marker and ordinary JSON serialization is denied outside an allowlisted personal-store or normalized household-response converter. [SensitiveData] on a household record causes logging/telemetry serializers to redact the whole object; it does not grant a generic serializer access. Structured telemetry uses an event allowlist with no free-form payload and tests prove installation identifiers, dates, tokens, target claims, and financial values never reach request logs, metrics, traces, or third-party APM sinks.

The household copy of a job or offer is deliberately a projection. It has no pointer that authorizes the household service to retrieve the private source record. Each projection is an explicit selection; the field policy applies to that projection only and never means “share every saved job” or grants item discovery in the personal workspace. ProjectionScope binds item-specific policy to that projection; the null scope is reserved for the single scenario coordination projection.

public enum ProjectionKind { CurrentRole, SavedJob, Offer }
public enum EmploymentState
{
    Unknown,
    ContinuingCurrentRole,
    Searching,
    ConsideringSavedJob,
    AcceptingOffer,
    EmploymentGap,
    NotSeeking
}

[SensitiveData]
public sealed record EmploymentOptionProjection(
    ProjectionId Id,
    HouseholdId HouseholdId,
    ScenarioId ScenarioId,
    MembershipRef OwnerMembership,
    ProjectionKind Kind,
    ScenarioAudienceSnapshotId AudienceSnapshotId,
    ScenarioAudienceGeneration AudienceGeneration,
    string SourceRevisionDigest,
    string? MemberChosenLabel,
    string? RoleSummary,
    string? EmployerSummary,
    PublishedScenarioGeography? WorkRegion,
    IReadOnlySet<WorkArrangement>? WorkArrangements,
    SharedRemoteWorkContext? RemoteWorkContext,
    MoneyEvidence? GrossPay,
    HoursEvidence? ExpectedHoursPerWeek,
    BenefitProjection? Benefits,
    PublishedScenarioWindow? StartWindow,
    IReadOnlyList<ConstraintEvidence> Constraints,
    IReadOnlyList<SharedCitation> Sources,
    IReadOnlySet<SharedField> EffectiveFields,
    int SharingPolicyVersion,
    string EffectiveFieldMaskHash,
    int Revision,
    DateTimeOffset PublishedAt);

public abstract record MoneyEvidence;

public sealed record UnknownMoney()
    : MoneyEvidence;

public sealed record ExactMoney(decimal Value, CurrencyCode Currency, MoneyPeriod Period)
    : MoneyEvidence;

public sealed record RangeMoney(decimal Low, decimal High, CurrencyCode Currency, MoneyPeriod Period)
    : MoneyEvidence;

public enum MoneyPeriod { Hour, Week, Month, Year, OneTime }

public readonly record struct CurrencyCode(string Value);

public abstract record SignedMoneyEvidence;
public sealed record UnknownSignedMoney() : SignedMoneyEvidence;
public sealed record ExactSignedMoney(decimal Value, CurrencyCode Currency, MoneyPeriod Period)
    : SignedMoneyEvidence;
public sealed record RangeSignedMoney(decimal Low, decimal High, CurrencyCode Currency, MoneyPeriod Period)
    : SignedMoneyEvidence;

public abstract record HoursEvidence;
public sealed record UnknownHours() : HoursEvidence;
public sealed record ExactHours(decimal Value) : HoursEvidence;
public sealed record RangeHours(decimal Low, decimal High) : HoursEvidence;

public abstract record BenefitProjection;
public sealed record UnknownBenefits() : BenefitProjection;
public sealed record SharedBenefitSummary(IReadOnlySet<BenefitCategoryCode> Categories)
    : BenefitProjection;

public sealed record SharedCitation(SharedCitationKind Kind, PublicSourceId SourceId);

public sealed record ConstraintEvidence(ConstraintCode Code, ConstraintParameter Parameter);
public abstract record ConstraintParameter;
public sealed record NoConstraintParameter() : ConstraintParameter;
public sealed record MaximumDurationEvidence(DurationEvidence Value) : ConstraintParameter;
public sealed record MaximumDistanceBucket(DistanceBucketCode Value) : ConstraintParameter;
public sealed record MaximumMoneyEvidence(MoneyEvidence Value) : ConstraintParameter;
public sealed record ScenarioWindowConstraint(PublishedScenarioWindow Value) : ConstraintParameter;
public sealed record ScenarioGeographyConstraint(PublishedScenarioGeography Value) : ConstraintParameter;

public enum PeerProjectionState { Available, Stale }

public sealed record PeerScenarioDto(
    ScenarioId Id,
    MoveReason? Reason,
    MoveLifecycle? Lifecycle,
    PublishedScenarioGeography? Geography,
    PublishedScenarioWindow? Window,
    ConusContext? ConusContext,
    bool CallerConfirmed,
    bool IsReady);

public sealed record PeerMemberCoordinationProjection(
    MembershipRef OwnerMembership,
    PeerProjectionState State,
    MoveParticipation? Participation,
    EmploymentState? EmploymentState,
    SearchPlanStatus? SearchStatus,
    PublishedScenarioGeography? SearchGeography,
    SearchRadiusBucket? SearchRadius,
    PublishedScenarioWindow? MoveAvailabilityWindow,
    PublishedScenarioWindow? SearchStartWindow,
    SharedLicenseTransferEvidence? LicenseTransfer,
    SharedRemoteWorkContext? RemoteWorkContext,
    IReadOnlySet<WorkArrangement>? WorkArrangements,
    IReadOnlyList<string>? TargetRoleSummaries,
    string? MemberChosenSummary);

public sealed record PeerEmploymentOptionProjection(
    MembershipRef OwnerMembership,
    PeerProjectionState State,
    ProjectionKind Kind,
    string? MemberChosenLabel,
    string? RoleSummary,
    string? EmployerSummary,
    PublishedScenarioGeography? WorkRegion,
    IReadOnlySet<WorkArrangement>? WorkArrangements,
    SharedRemoteWorkContext? RemoteWorkContext,
    MoneyEvidence? GrossPay,
    HoursEvidence? ExpectedHoursPerWeek,
    BenefitProjection? Benefits,
    PublishedScenarioWindow? StartWindow,
    IReadOnlyList<ConstraintEvidence> Constraints,
    IReadOnlyList<SharedCitation> Sources);

[SensitiveData]
public sealed record ProjectionTombstone(
    ProjectionId Id,
    HouseholdId HouseholdId,
    ScenarioId ScenarioId,
    MembershipRef OwnerMembership,
    int LastRevision,
    DateTimeOffset RevokedAt);

The variants make None, range, and exact mutually exclusive. None publishes UnknownMoney with no amount, currency, or period; Range omits the exact value; Exact has no hidden range. Arithmetic is validated at construction and at every API boundary: RangeMoney.Low < RangeMoney.High and RangeHours.Low < RangeHours.High are strict, finite, non-negative-domain invariants; exact money/hours are also finite and non-negative. DurationRange is finite, non-negative, and requires Low < High. A range is an owner-chosen disclosure and may be narrow; the product does not claim an arbitrary privacy floor or silently promote it to exact. Arithmetic is currency- and period- aware interval arithmetic with defined add, subtract, and scale operations; mixed currencies fail incomplete until an explicit sourced conversion exists. A range plus an exact value stays a range and an unknown contribution makes the affected total incomplete. CurrencyCode is accepted only from a versioned server catalog of supported ISO 4217 codes; unknown, case-smuggled, confusable, or client-invented codes fail at every constructor and API boundary. Only SignedMoneyEvidence permits finite negative values or intervals crossing zero, and it is restricted to net cash-flow output; its range requires finite Low < High. Income, cost, premium, assistance, and tax-liability magnitudes remain non-negative. Hourly compensation also requires ExpectedHoursPerWeek; its unknown/range/exact union follows the same least-permissive disclosure rule. An hourly option with unknown hours remains incomplete for monthly, annual, tax, and cash-flow calculations instead of assuming full-time work. Without SharedField.BenefitsSummary, Benefits is null/omitted; a granted field with missing private value emits UnknownBenefits. GrossPay and ExpectedHoursPerWeek follow the same absent-field-null, granted-but-unknown-typed rule. Shared benefit categories are closed codes and never carry private policy/member/health details. License feasibility appears only with SharedField.LicensureFeasibility; cost appears only with LicensureCost and LicensureCostDisclosure; duration appears only with LicensureDuration and DurationDisclosure. Ungranted siblings are null/omitted; a granted None or missing private value emits the matching typed unknown. Profession is separately nullable and appears only with LicensureProfessionCategory; cost-only or duration-only sharing never reveals it. Licensure money must use MoneyPeriod.OneTime; recurring compensation rejects OneTime, and conversion between one-time and recurring periods requires an explicit sourced calculation. Path detail, reimbursement expectation, and destination jurisdiction remain private in MVP. SharedRemoteWorkContext uses only non-locating closed context codes; any future jurisdiction-bearing remote/license output requires a geography grant at least as precise as that jurisdiction and is otherwise omitted.

EmploymentOptionProjection is produced only by the same server-side normalization boundary. The live field mask controls every property, including region and timing. For an employment-option policy, GeographyPrecision caps WorkRegion and TimePrecision caps StartWindow; for a coordination policy SearchGeography, MoveAvailability, and SearchTiming independently gate their matching properties. Every destination/scenario-scoped geography or time value is capped by the least precise of the owner policy, the current ScenarioVisibility grant, and the live published scenario quanta; the factory rejects or re-quantizes a finer value. Published values must validate no finer than that combined cap. Constraints are normalized codes, not free text; citations may identify public job or calculation sources but never orders, private records, or authenticated URLs. Member-authored financial notes remain personal and are not part of the shared MoneyEvidence union. Every member-chosen label or summary is length-bounded and validated at the projection boundary. The server rejects internal ids, credentials, raw URLs, and money-like text from every free-text field; it never strips and accepts. Internal ids, credentials, and raw URLs are unconditional rejects; money-like text is allowed only in the typed compensation representation when compensation is shared exactly. Mutation tests attempt to smuggle amounts and private citations through every text-bearing property. The validator is field-aware and rejects rather than strips location, installation, employer, money, or civil-date/timing tokens whenever the matching typed field and precision/disclosure grant is absent. Tests cover every text-bearing property crossed with every ungranted sensitive sibling. WorkArrangements and shared remote context require their matching fields. Constraints is empty unless JobConstraints is granted, and Sources is empty unless PublicSources is granted. A projected money constraint uses MaximumMoneyEvidence and ConstraintMoneyDisclosure; ExactMoney is accepted only when JobConstraints is granted and that disclosure is Exact, range accepts only RangeMoney, and None accepts only UnknownMoney. It never borrows CompensationDisclosure or the compensation field. ScenarioGeographyConstraint additionally requires granted WorkRegion and non-null geography precision; ScenarioWindowConstraint additionally requires granted EmploymentTiming and non-null time precision. Both are quantized to the same least-permissive owner-policy/scenario cap as their matching property; ungranted or finer constraint parameters fail closed.

SourceRevisionDigest, raw projection revisions, and per-update PublishedAt timestamps are storage and concurrency metadata, not peer DTO fields. A peer read receives only the current policy-normalized value and PeerProjectionState. Available requires the live normalized payload. Stale hard-nulls or empties every payload property and exposes no reason, revision, time, actor, or prior value; a revoked or unauthorized projection is omitted rather than represented as stale. The owner may see detailed revision history through self-only access history. Household DTOs reference SharedRemoteWorkContext only; an assembly test forbids the private remote-work type in household contracts.

Revocation never leaves an active projection row with sensitive payload beside a RevokedAt flag. The transaction hard-deletes the active record and writes only ProjectionTombstone; a stripped live twin is forbidden. An active projection must reference a live policy with RevokedAt == null; all reads deny a revoked policy, and retained policy/projection tombstones contain no sensitive payload. Tombstones, LastRevision, and RevokedAt are storage-internal or owner self-history only; peer APIs, indexes, watches, and exports cannot enumerate them. The same transaction evicts every in-memory HouseholdComparison that consumed the revoked ProjectionId, invalidates the plan input digest, and leaves only a generic incomplete result; recalculation may occur only from the remaining currently authorized inputs. No stale comparison, cache, export, or client snapshot is a fallback. Direct store, index, analytics, cache, export, and API tests prove no revoked payload remains reachable. PublicSourceId is assigned from a server-maintained public-source catalog; clients cannot supply an arbitrary identifier or encode data into it.

Every derived output is disclosure-monotone: it can never be more precise than the least-permissive contributing input. That rule applies to member rows, household totals, joint taxes, benefits, cash-flow timelines, and comparisons between plans. An exact joint tax or total is shown only when every contributor consented to exact inputs; otherwise it remains a range or incomplete. The UI must not offer plan toggles or deltas that isolate a hidden contribution by subtraction. The server calculation and read policy must also reject or coarsen plan variants, direct API queries, and deltas that isolate a hidden contribution. Enforcement is deterministic and stateless across callers, sessions, tokens, and devices: all variants use a scenario-wide disclosure fingerprint derived from the least-permissive active consent, and variants that could isolate one input resolve to the same coarse bucket or incomplete state. No history-dependent or more revealing prior result is a fallback. “Household total only” is not presented as private: in a two-adult household it can reveal the other adult's contribution.

[SensitiveData]
public sealed record HouseholdPlan(
    HouseholdPlanId Id,
    HouseholdId HouseholdId,
    ScenarioId ScenarioId,
    ResourceCreator CreatedBy,
    int Version,
    string Label,
    IReadOnlyList<MemberEmploymentSelection> EmploymentSelections,
    IReadOnlyList<TaxUnit> TaxUnits,
    HouseholdCompositionConfirmation Composition,
    HouseholdCostAssumptions SharedCosts,
    BenefitCoverageSelection BenefitCoverage,
    RelocationCostPlan Relocation,
    IReadOnlyList<OneTimeCostSelection> OneTimeCosts,
    IReadOnlyList<PlanConstraintSelection> Constraints,
    ComparisonWindowSelection ComparisonWindow,
    PlanEvaluationPin? LastEvaluationPin,
    DateTimeOffset UpdatedAt);

public sealed record PlanEvaluationPin(
    string InputRevisionDigest,
    string CalculatorVersion,
    DateTimeOffset CalculatedAt);

public readonly record struct CostOccurrenceId(Guid Value);

public sealed record HouseholdCompositionConfirmation(
    MembershipRef ProposedBy,
    IReadOnlySet<MembershipRef> CoveredMembers,
    IReadOnlySet<AnonymousDependentRef> AnonymousDependents,
    int AdditionalAnonymousNonDependentAdultCount,
    int AdditionalAnonymousNonDependentChildCount,
    int Version,
    string ContentHash,
    IReadOnlyDictionary<MembershipRef, string> Confirmations);

public abstract record AnonymousDependentRef(string OpaqueId);
public sealed record AnonymousChildDependentRef(string OpaqueId)
    : AnonymousDependentRef(OpaqueId);
public sealed record AnonymousQualifyingRelativeRef(string OpaqueId)
    : AnonymousDependentRef(OpaqueId);

public sealed record MemberEmploymentSelection(
    MembershipRef Member,
    EmploymentState State,
    ProjectionId? ProjectionId,
    int? ProjectionRevision,
    string? ProjectionContentDigest,
    int Version,
    string ContentHash);

public sealed record HouseholdCostAssumptions(
    MembershipRef ProposedBy,
    IReadOnlySet<MembershipRef> AffectedMembers,
    IReadOnlyList<SharedCostInput> Costs,
    int Version,
    string ContentHash,
    IReadOnlyDictionary<MembershipRef, string> Confirmations);

public enum CashTaxTreatment
{
    Unknown,
    PostTaxCash,
    PreTaxDeductionCandidate,
    TaxCreditBasis,
    TaxableAssistance
}

public sealed record SharedCostInput(
    SharedCostCategoryCode Category,
    MoneyEvidence Amount,
    CashTaxTreatment ServerDerivedTaxTreatment);

public sealed record BenefitCoverageSelection(
    MembershipRef ProposedBy,
    IReadOnlySet<MembershipRef> AffectedMembers,
    IReadOnlyList<BenefitCoverageChoice> Choices,
    int Version,
    string ContentHash,
    IReadOnlyDictionary<MembershipRef, string> Confirmations);

public sealed record BenefitCoverageChoice(
    BenefitCategoryCode Category,
    IReadOnlySet<MembershipRef> CoveredMembers,
    PublishedScenarioWindow CoverageWindow,
    MoneyEvidence? Premium,
    CashTaxTreatment ServerDerivedPremiumTaxTreatment);

public enum BenefitCoverageScopeCode { SelfOnly, AdultPlusOne, Family, OtherConfirmed }

public sealed record RelocationCostPlan(
    MembershipRef ProposedBy,
    IReadOnlySet<MembershipRef> AffectedMembers,
    IReadOnlyList<RelocationCostInput> CostsAndAssistance,
    int Version,
    string ContentHash,
    IReadOnlyDictionary<MembershipRef, string> Confirmations);

public sealed record RelocationCostInput(
    CostOccurrenceId OccurrenceId,
    RelocationCostCategoryCode Category,
    MoneyEvidence Amount,
    OccurrenceTiming Occurrence,
    bool IsAssistance,
    CashTaxTreatment ServerDerivedTaxTreatment);

public sealed record ComparisonWindowSelection(
    MembershipRef ProposedBy,
    IReadOnlySet<MembershipRef> AffectedMembers,
    PublishedScenarioWindow Window,
    int Version,
    string ContentHash,
    IReadOnlyDictionary<MembershipRef, string> Confirmations);

public sealed record PlanConstraintSelection(
    ConstraintId Id,
    MembershipRef ProposedBy,
    IReadOnlySet<MembershipRef> AffectedMembers,
    ConstraintCode Code,
    ConstraintParameter Parameter,
    int Version,
    string ContentHash,
    IReadOnlyDictionary<MembershipRef, string> Confirmations);

public sealed record TaxUnit(
    TaxUnitId Id,
    MembershipRef ProposedBy,
    IReadOnlySet<MembershipRef> Members,
    IReadOnlySet<AnonymousDependentRef> ClaimedDependents,
    IReadOnlySet<MembershipRef> ClaimedMemberDependents,
    FilingStatus FilingStatus,
    int TaxYear,
    IReadOnlyList<TaxJurisdictionPeriod> JurisdictionPeriods,
    int Version,
    string ContentHash,
    IReadOnlyDictionary<MembershipRef, string> Confirmations);

public sealed record TaxJurisdictionPeriod(
    TaxJurisdictionLevel Level,
    JurisdictionId Jurisdiction,
    PublishedScenarioWindow EffectiveWindow,
    TaxAllocationBasis AllocationBasis);

public enum FilingStatus { Single, MarriedFilingJointly, MarriedFilingSeparately, HeadOfHousehold }
public enum TaxJurisdictionLevel { Federal, State, Local }
public enum TaxAllocationBasis { Residence, WorkLocation, SourceIncome, FullYear }

public sealed record OneTimeCostSelection(
    OneTimeCostId Id,
    CostOccurrenceId OccurrenceId,
    MembershipRef ProposedBy,
    IReadOnlySet<MembershipRef> AffectedMembers,
    OneTimeCostCategoryCode Category,
    MoneyEvidence Amount,
    OccurrenceTiming Occurrence,
    CashTaxTreatment ServerDerivedTaxTreatment,
    int Version,
    string ContentHash,
    IReadOnlyDictionary<MembershipRef, string> Confirmations);

public abstract record OccurrenceTiming;
public sealed record ScheduledOccurrence(PublishedScenarioWindow Window) : OccurrenceTiming;
public sealed record UnscheduledOccurrence() : OccurrenceTiming;

[SensitiveData]
public sealed record HouseholdComparison(
    HouseholdId HouseholdId,
    ScenarioId ScenarioId,
    HouseholdPlanId PlanId,
    IReadOnlyList<MemberEmploymentRow> Employment,
    HouseholdTaxRow? Taxes,
    IReadOnlyList<SharedCostRow> SharedCosts,
    IReadOnlyList<BenefitCoverageRow> BenefitCoverage,
    IReadOnlyList<RelocationCostRow> RelocationCosts,
    IReadOnlyList<OneTimeCostRow> OneTimeCosts,
    IReadOnlyList<CashFlowPeriodRow> Timeline,
    IReadOnlyList<ConstraintEvidence> HardConstraints,
    PeerComparisonStatus Status);

public sealed record PeerComparisonStatus(bool IsComplete);

public sealed record HouseholdTaxRow(MoneyEvidence Liability);
public sealed record SharedCostRow(SharedCostCategoryCode Category, MoneyEvidence Amount);
public sealed record BenefitCoverageRow(
    BenefitCategoryCode Category,
    BenefitCoverageScopeCode CoverageScope,
    PublishedScenarioWindow CoverageWindow,
    MoneyEvidence? Premium);
public sealed record RelocationCostRow(
    RelocationCostCategoryCode Category,
    MoneyEvidence Amount,
    OccurrenceTiming Occurrence,
    bool IsAssistance);
public sealed record OneTimeCostRow(
    OneTimeCostCategoryCode Category,
    MoneyEvidence Amount,
    OccurrenceTiming Occurrence);
public sealed record CashFlowPeriodRow(
    PublishedScenarioWindow Period,
    SignedMoneyEvidence NetCashFlow);

[SensitiveData]
internal sealed record HouseholdComparisonDiagnostics(
    IReadOnlySet<MissingInputCode> MissingInputs,
    IReadOnlySet<SystemCapabilityCode> CapabilityFailures,
    int PlanVersion,
    string InputRevisionDigest,
    string CalculatorVersion,
    DateTimeOffset CalculatedAt);

public sealed record MemberEmploymentRow(
    MembershipRef Member,
    EmploymentState State,
    MoneyEvidence? GrossPay,
    HoursEvidence? ExpectedHoursPerWeek,
    PublishedScenarioWindow? MoveAvailabilityWindow,
    PublishedScenarioWindow? EmploymentStartWindow);

internal enum MissingInputCode
{
    PlanInputUnavailable,
    EmploymentSelectionUnavailable,
    TaxUnitUnconfirmed,
    CompositionUnconfirmed,
    SharedCostUnconfirmed,
    BenefitCoverageUnconfirmed,
    RelocationCostUnconfirmed,
    RelocationCostTimingUnknown,
    OneTimeCostUnconfirmed,
    OneTimeCostTimingUnknown,
    ConstraintUnconfirmed,
    ComparisonWindowUnconfirmed,
    ProjectionUnavailable
}

internal enum SystemCapabilityCode { TaxCombinationUnsupported }

public sealed record PeerPlanDto(
    HouseholdPlanId Id,
    ScenarioId ScenarioId,
    string Label,
    MemberEmploymentSelection? CallerEmploymentSelection,
    PeerComparisonStatus Status);

Tax units are separate from members. Two spouses filing jointly must be calculated once on combined taxable income; summing two “married filing jointly” estimates would double-apply brackets and deductions. For a plan, the in-scope adult set is the union of composition CoveredMembers and members with an employment selection. Every in-scope adult must confirm and appear exactly once per tax year as either a TaxUnit.Members entry or a ClaimedMemberDependents entry; an unassigned adult yields TaxUnitUnconfirmed. One person may belong to at most one unit per plan and tax year. An adult child may be a separate tax unit even while participating in shared housing. An active member claimed as a dependent cannot also appear in Members, may be claimed by only one tax unit per plan/year, and must confirm the exact tax-unit content hash; the application never infers dependency from age, relationship, or co-residence. Until a multi-earner calculator supports the declared year and jurisdiction periods, the tax row remains incomplete; it never falls back to summing single-earner estimates. ContentHash covers Members, ClaimedMemberDependents, ClaimedDependents, filing status, year, and every field of every jurisdiction period. TaxJurisdictionPeriod carries a typed jurisdiction, jurisdiction level, scenario-capped PublishedScenarioWindow, and allocation basis. Periods may overlap across levels (for example, federal and state) but cannot overlap within the same level and jurisdiction; this lets one confirmed tax unit model part-year origin and destination filings without putting the same person in multiple tax units for the year. Unsupported combinations remain incomplete. Confirmations is server-derived from each affected member's dedicated action; its key set is exactly Members ∪ ClaimedMemberDependents and every value matches the current content hash. Any mutation creates a new version and clears stale confirmations. Plan create/update DTOs cannot set confirmations or claim confirmation for another subject. The MVP filing cardinality table is closed: MarriedFilingJointly requires exactly two adult Members; Single, MarriedFilingSeparately, and HeadOfHousehold require exactly one. Claimed dependents do not change adult-member cardinality; invalid or unsupported status/year/jurisdiction combinations fail incomplete. ProposedBy records the authenticated author of the current composition or tax-unit version and is server-derived. MemberEmploymentSelection.Member is both owner and proposer because only that member may write the selection; a peer cannot propose or confirm an employment choice for them. The authenticated write is the member's confirmation of ContentHash; withdrawal removes the selection rather than retaining a separate or multi-member decision map.

HouseholdCompositionConfirmation explicitly names which active membership ids the shared cost assumptions cover and how many additional anonymous non-dependent adults/children are included. It is user-confirmed for each plan. Linked membership never automatically increments or subtracts FamilyProfile.Adults. Private members' existing FamilyProfile values are never merged. A shared plan begins with newly proposed cost assumptions and requires affected-member confirmation before those values participate in a comparison. The plan creates typed opaque AnonymousChildDependentRef or AnonymousQualifyingRelativeRef values for confirmed anonymous dependents; the user explicitly selects the category and the product never infers it. The non-dependent counts exclude these refs. A dependent may be claimed by at most one tax unit per plan/year. The composition content hash covers covered members, both additional anonymous non-dependent counts, and typed anonymous people. Dependent allocation exists only in confirmed TaxUnit content. Composition mutation may not orphan a claimed dependent. It either rejects removal/retyping while a tax unit references the ref, or atomically mints new tax-unit versions that drop the invalid claim, recomputes hashes, clears those confirmations, and makes evaluation incomplete. HouseholdCostAssumptions, RelocationCostPlan, BenefitCoverageSelection, comparison-window selection, and each OneTimeCostSelection are independent versioned sub-resources with their own server-derived proposer, content hash, and affected-member confirmation map. Each hash covers its affected members plus every typed value in that sub-resource; the comparison-window hash covers its normalized window. Mutating one clears only its own confirmations; the plan repository validates every sibling version/hash atomically before evaluation. Each TaxUnit.ContentHash also covers its dependent refs. Every MembershipRef in a plan must resolve to a current confirmed participant of that exact scenario; non-participant, other-scenario, departed, or stale membership refs fail closed. AffectedMembers and ProposedBy are server-derived and rejected in client DTOs. Each resource defines one exact affected set, not a client-expandable minimum. Composition, shared costs, relocation costs, comparison window, plan constraints, and one-time costs use all current scenario participants; tax units use exactly members plus claimed-member dependents; benefit selection uses exactly the union of its choices' covered members. For benefits, every choice satisfies CoveredMembers ⊆ BenefitCoverageSelection.AffectedMembers ⊆ composition.CoveredMembers ⊆ scenario.Participants, and every covered adult confirms the parent selection hash. Composition has a non-empty server-derived quorum: every current scenario participant confirms, including when it contains only anonymous people. A client/DTO cannot shrink the required confirmer set of a live version; server-side leave/removal always mints new plan/sub-resource versions that remove the departed ref from every member-bearing field, recompute sibling hashes, clear the evaluation pin, and clear survivor confirmations where the referenced-member set or value changed. Rebinding may never preserve a departed key. BenefitCoverageScopeCode is server-derived from the confirmed choice/composition and is the only coverage subject emitted in a comparison; it never serializes CoveredMembers or dependent refs.

MemberEmploymentSelection.ContentHash covers State, ProjectionId, ProjectionRevision, and ProjectionContentDigest. The closed pin matrix is: ContinuingCurrentRole requires a live CurrentRole projection; ConsideringSavedJob requires SavedJob; AcceptingOffer requires Offer; Unknown, Searching, EmploymentGap, and NotSeeking forbid all pin fields and contribute no pay or employment timing. Only a pin-required state with a missing, tombstoned, wrong-kind, wrong-revision, or wrong-digest input yields ProjectionUnavailable; state-only plans such as “spouse searches” remain evaluable with incomplete money. A ProjectionId is globally unique and permanently retired on revoke. Live republish increments revision and changes the digest; post-revoke publish mints a new id, so no old pin can resurrect. Evaluation never auto-upgrades, and republish requires the member to write a new selection. MissingInputCode is an allowlisted, payload-free internal enum and can never embed a membership, projection, field, or owner identity. Peer comparison DTOs never serialize the internal reason set: member-owned withdrawal/revoke/mismatch and unconfirmed shared resources all collapse to PeerComparisonStatus(IsComplete: false). Capability failures use the separate internal SystemCapabilityCode set and are equally indistinguishable to peers. Self-only diagnostics may identify only the caller's own missing confirmation or a non-personal system capability. Peer visibility of any state-only employment selection additionally requires the member's live coordination policy to grant SharedField.EmploymentState to that caller's frozen audience; otherwise the row is omitted. Pin acceptance performs one lookup proving session ownership, exact path household/scenario, current participation, live policy/snapshot, matrix kind, and matching id/revision/digest. Cross-scenario pins return a generic not-found/conflict result and never become plan references.

Constraint parameters use the closed ConstraintParameter union rather than free-form dictionaries or values encoded into ConstraintCode. Plan inputs and projected ConstraintEvidence use the same normalized parameter shape; raw decimal miles and raw TimeSpan parameters are impossible. A plan money constraint is MaximumMoneyEvidence(MoneyEvidence) and becomes visible only to its affected adults as a versioned resource they explicitly confirm; exact is allowed only on that explicit shared-money path and derived peer output still obeys the least-permissive disclosure fingerprint. Unsupported parameter/code pairs fail validation. Scenario window/geography constraints and every plan tax/cost locality are server-quantized no finer than the active scenario visibility grants; exact civil dates, addresses, or finer jurisdictions are rejected.

One-time costs and relocation assistance use OccurrenceTiming: UnscheduledOccurrence makes runway/timeline output incomplete and emits the internal OneTimeCostTimingUnknown or RelocationCostTimingUnknown code for the matching source; ScheduledOccurrence is scenario-capped and amounts are charged only in the least-permissive authorized time bucket containing the occurrence. They are never silently forced into a finer month or amortized. Property fixtures cover a dated deposit and assistance offset. Move-specific relocation/assistance categories and general one-time categories are closed, disjoint enums. CostOccurrenceId is server-minted and unique within the plan; the same occurrence id cannot appear in both lists or more than once, so one economic event can never be counted twice.

Tax treatment is never a client assertion. A server catalog keyed by category, tax year, jurisdiction, and supported benefit/employer facts derives CashTaxTreatment; unknown or disputed treatment produces Unknown and makes tax incomplete, never a fabricated default. Confirmed gross income plus confirmed taxable assistance, pre-tax deduction candidates, and credit-basis inputs enter the supported multi-earner tax calculation. Only confirmed post-tax cash outflows and post-tax premiums are subtracted afterward. A benefit with no confirmed Premium is unpriced, carries CashTaxTreatment.Unknown, and never enters cash math. One-time amounts require MoneyPeriod.OneTime; recurring costs reject that period unless a separately sourced allocation rule exists.

PeerPlanDto is the only plan shape on peer routes, never serialization of HouseholdPlan; an assembly test forbids the storage type from peer controllers:

Field class Peer plan/read rule
Employment selection state, projection id/revision/digest/hash Full detail self-only; peers never receive pins, hashes, cadence metadata, or another member's unnormalized state
Live employment evidence A peer receives only the current policy-normalized row when both current participation and the policy audience snapshot authorize that peer; otherwise the row is omitted
Pending shared plan sub-resource Values visible only to its server-derived affected adults so they can decide; every non-party receives only generic plan incomplete
Confirmed tax unit Members and claimed-member dependents may see their confirmed structure; non-parties never see filer membership, dependent allocation, or per-unit tax
Confirmed shared cost/benefit/relocation/one-time/constraint Values visible only to affected adults and then only through the normalized type; comparison output contains no proposer or confirmation data
Evaluation pin, input digest, calculator version, timestamps Storage/self diagnostics only; absent from every peer plan/comparison DTO

The peer comparison types above are the complete allowlist. Tax is one disclosure-monotone household row, never per filer/member. Timeline rows contain only scenario-capped period plus household net evidence, never contributor legs or attributed employment gaps. Cost/benefit/constraint rows contain no member, proposer, confirmation, internal id, missing reason, revision, or source field. Unknown row shapes fail serialization contract tests. Every comparison is materialized for its caller. Its authorized input set contains only projections where that caller has live participation plus membership in the exact policy snapshot, and plan sub-resources where the caller is an affected/confirming party. Unauthorized inputs become unavailable and make the result incomplete; they never silently fuel tax, totals, timeline, cost, benefit, constraint, cache, export, print, share, or accessibility output. Two callers may therefore receive different safe materializations from the same plan. Each MemberEmploymentRow is a per-property merge, not a projection shortcut: employment state and move availability come only from the live authorized coordination projection, while option pay, hours, and employment start come only from the exact selected live option projection. Both revisions and policies are included in the internal input digest; an unauthorized or absent side stays omitted/incomplete.

All confirmation maps remain server-internal. Peer plan DTOs expose only the caller's own state plus a generic resource ready/incomplete state, never proposer identity, per-member confirmation maps, counts, or withdrawal transitions. MemberEmploymentRow contains only the policy-normalized evidence above; it has no IsComplete, private id, source citation, revision digest, or finer timing than the scenario grant. An unavailable/non-live row is omitted and only aggregate PeerComparisonStatus changes; roster or omission can still reveal the member and is covered by the residual warning. Plan and sub-resource mutations are rate-limited per membership/plan, pending confirmation-changing writes are capped/cooled down, and no re-consent push is sent to another adult.

Every member-bearing plan value—including employment selections, composition, tax units, benefit coverage, constraints, proposals, and confirmations—uses MembershipRef, never a global SubjectId. Plan labels and every other shared free-text field use the same length, identifier, URL, credential, and money-smuggling validation as projection summaries.

Storage and authorization

Hard prerequisites

This is not safe to implement on the current server data path yet:

  1. Stable person identity. The current Google cohort path derives a private workspace id from an email hash. Email is a mutable address, not a durable account identity. Household membership must bind to a stable provider subject id before invitations or ownership exist.
  2. Tenant-scope every private input used by the feature. Ordinary job, profile, family, and offer services still include process-wide LocalJsonStore repositories. Server edition deliberately fails those routes closed unless an unsafe pilot-only legacy-store unlock is enabled. Do not weaken that guard. Migrate the required personal services to tenant-scoped persistence and scoped DI first.
  3. Protect the existing family route. FamilyController follows the desktop trust model and does not carry the local-token protection used by jobs/offers. It must not gain linked-account data. Any separate hardening of that route should preserve today's local-only feature while the linked feature uses new authenticated household endpoints.
  4. Keep account scope and household scope typed and separate. A personal tenant id cannot double as a household id, and no generic repository may accept either interchangeably.
  5. Provide a transactional identity/membership authority. Candidate reservation, admission activation, subject-to-membership index, and empty-grant membership insert must commit under one database transaction and idempotency key; a distributed best-effort sequence is not an MVP substitute.

These are release blockers, not cleanup suggestions.

Personal workspace

Personal tenant scope remains the only home for:

  • full profile, resume, work history, documents, messages, applications, credentials, and vault data;
  • exact military status, eligibility evidence, orders, documents, precise location, and dates;
  • private job-search criteria, complete job records, offer records, and application activity;
  • draft projections before the owner publishes them.

Household coordination realm

The shared realm contains only:

  • household metadata, invitations, accepted membership, and audit events;
  • relocation scenario facts intentionally entered at household scope;
  • each member's versioned sharing policy and normalized coordination/job/offer projections — never the full member intent, search plan, or source record;
  • household plans containing versioned shared sub-resources, employment pins limited to id/revision/digest, evaluation-pin metadata, and server-internal confirmation maps—never private source records; comparison output rows are never durable household documents.

Do not expose the household namespace through ITenantDocumentStore to feature services. Add an IHouseholdStore/IHouseholdAuthorizationService boundary that receives the authenticated subject, resolves membership, applies per-resource ownership, and then uses an opaque storage namespace such as household:{id} internally. A caller-supplied SubjectId, personal TenantId, or owner id is never trusted.

Action Required authority
Create household Authenticated adult account with no active-membership/pending-admission reservation; atomically mint creator membership with the closed bootstrap set ManageHousehold, ManageInvitations, EditScenario, EditPlan, and ManagePlans
List households Authenticated subject; return only households containing the caller's active membership
Create invitation Accepted member with ManageInvitations; while unmanaged, any active adult may create a rate-limited invitation with the same empty join grant, but activation still requires every current adult's admission confirmation
List invitations ManageInvitations; while unmanaged, an invitation creator may list only their own redacted rows. No response exposes account, delivery, view, or decline state
Cancel invitation ManageInvitations in the invitation's household for a still-Sent invitation; while unmanaged, an invitation creator may cancel only their own still-Sent row. Accepted admission cannot be canceled by the inviter
Accept/decline invitation Authenticated identity bound to the invitation; accept creates only a pending admission and decline is silent to the inviter
Read/confirm/withdraw pending admission Household-scoped reads/confirmations are only for each adult active at candidate acceptance, self only; final confirmation creates active membership. Candidate minimal self-read/withdraw is only under /api/me/household-admissions and grants no household access; all withdrawal stays peer-unattributed
List/create/delete invitation blocks Authenticated subject for self only; block targets are server-derived from the invitation flow
Read household Accepted member; response is the minimum membership display
List members Accepted member; return only active principals as MembershipId plus optional member-chosen household label; never return departed rows, LeftAt, or tombstones
Read/update private relocation drafts Authenticated personal-record owner; no household grant until publish
Create private move intent/search plan Authenticated owner whose confirmation matches the current target-scenario hash
Read/update/delete existing private intent/search plan Authenticated personal-record owner; no surviving household grant required
First-publish a relocation draft Always-on authenticated resource-owner action with active adult membership; it creates a self-only initial scenario and is not a coordinator mutation
Publish/republish into an existing scenario Authenticated resource owner whose confirmation matches the pre-write target-scenario hash plus live kind-scoped policy
List scenarios Accepted member; confirmed participants receive policy-filtered scenarios, a self target/candidate receives only its appropriate shell/preview, and every non-participant/non-target member receives an empty list
Read a scenario Accepted participant whose own confirmation matches the current visibility hash; field precision is enforced per property
Accept/decline/leave scenario participation Target candidacy and final preview confirmation are self only; current participants confirm only the candidate expansion hash; decline/removal is silent and removes self without leaving the household
Offer scenario candidacy Any confirmed current participant; creates a payload-free target-only offer and prompt, grants no scenario read, and is blocked by the target's self-only proposal block
Pause/resume scenario offers Authenticated subject for self only; pause is global or household-scoped, creates no peer-visible event, and survives membership rejoin
Write a scenario Confirmed participant with EditScenario; another adult's participation or visibility precision requires that adult's target-accept action and hash confirmation
Archive a scenario A confirmed participant with EditScenario may propose Archived; execution requires every current participant to confirm the new hash, while any member may always remove self independently
Write/revoke a policy Policy owner only
Resource-owner revoke/delete while unmanaged Still allowed for the policy/projection resource owner; first-publish of a new self-owned scenario also remains available
Publish/update a member projection Projection owner only, through an authenticated private-record publish command and the server normalization boundary; no household payload-write endpoint exists
Delete a member projection Projection owner only; the household delete route revokes the output and accepts no replacement payload
Read shared projections Confirmed current scenario participant who also belongs to each policy's frozen audience snapshot; return only live normalized rows and reauthorize every row. All peer projection/plan reads suspend while the realm is unmanaged
List/read plans Confirmed scenario participant; return only PeerPlanDto for visible scenarios and currently caller-authorized inputs
Create/update a plan Confirmed scenario participant with EditPlan; shared-plan DTOs reject employment selection, projection payload, actor, and confirmation fields
Select/withdraw employment The affected member only through the /me/employment-selection route; the selection may pin only that member's actively shared projection revision
Evaluate a plan Confirmed scenario participant whose confirmation matches the live hash; rate-limit the pure recompute, build the caller-specific authorized input set, reauthorize every live projection/sub-resource, and return only disclosure-monotone output
Confirm/withdraw tax-unit, benefit, constraint, shared-cost, relocation-cost, composition, comparison-window, or one-time-cost participation The affected member only, with active membership and current confirmed participation in that exact scenario; confirmation records are server-derived, version-bound, and never accepted from a plan DTO; withdrawal is not exposed to peers as a rejection event
Archive/delete a plan Plan creator or ManagePlans; any remaining confirmed participant may archive—but not hard-delete—an incomplete shell whose creator departed. It never deletes member projections or private inputs
Leave household Current member; no approval
Create/read/cancel household deletion request Create requires ManageHousehold; any active member may read or cancel the current payload-free request, and recreate is rate-limited/cooled down
Confirm/withdraw household deletion Current member for self only; withdrawal is immediate and accepts no actor field
Delete household ManageHousehold plus no other active members, or explicit confirmation from every active member; exit remains independently available
Propose/list/cancel coordination permission changes ManageHousehold; only the original proposer or another current ManageHousehold member may cancel a still-pending proposal
Accept/decline a coordination permission change The affected member only; acceptance is version-bound, and no role grants peer-data access
Block/unblock scenario or permission proposals Current member for self only; blocks create no peer-visible event and never require coordinator approval
Elect/relinquish coordination While unmanaged, a sole remaining member may self-elect; with multiple active members, election requires a version-bound proposal confirmed by every remaining adult. It grants only the closed bootstrap coordination permissions, never peer-data access
Read/create/confirm/cancel a coordinator challenge Any active adult other than the target may propose; every other active adult except the target must confirm the version-bound challenge. Eligible confirmers may read/cancel it; the target receives no actor detail and cannot veto, confirm, withdraw, or cancel. Success strips coordination permissions from every member, preserves membership, and leaves the realm unmanaged
Read shared audit summary Active member; return only non-timestamped, payload-free scenario summaries whose current visibility hash the caller confirmed
Read self access history Authenticated subject for self only

Every nested-resource query proves the full relationship in one authorization-aware lookup: for example, the plan must belong to the named scenario and that scenario to the named household. Checking membership against the path household while loading a free-standing plan id is forbidden.

MVP has no peer-to-peer forced-removal verb. Adults may leave themselves; household deletion requires the rule above. Abuse/support account action is a separate administrative security process, not a coordinator capability that can be used to eject partners and manufacture deletion authority. When a realm becomes unmanaged, all peer projection, plan, comparison, share, and export reads suspend; resource owners retain self views and revoke/delete rights. Recovery requires the documented election and entry mints new scenario audience snapshots, then recovery requires a fresh owner re-grant—old peer access never resumes automatically. Any adult may also leave or create a new household and republish only their own private records, so an election holdout cannot trap their data or keep monitoring it.

Every response is tenant- and subject-redacted. Detailed access/policy history is self-only. The shared household audit is limited to coarse, payload-free, non-timestamped scenario summaries; it omits member exit, invitation decline, and sharing-reduction events and does not expose another member's policy versions, projection actions, salary, job content, orders, or reduced-sharing activity. Audit retention must be a named, tested policy before implementation.

Desktop and cloud editions

Cross-device account linking needs a stable signed-in subject and shared coordination service. The initial linked-household feature should therefore require account sign-in. Desktop may keep personal records local-first and ask the server to publish a normalized household projection; the server is authoritative for policy enforcement, revisions, and tombstones. No undefined client-signing protocol is part of MVP. When an authenticated desktop record has not been personally synced, its /api/me/.../publish request may carry that owner's private input value over the protected session; the server treats it as ephemeral command input, performs the same ownership, policy, validation, quantization, and redaction pipeline, persists only normalized household output, and never accepts it on a household route. A route-specific observability rule disables request-body capture and replaces the body with a fixed SensitivePublishCommand marker in logs, traces, APM, exception reports, replay tooling, and analytics. Validation and ProblemDetails return only closed field/error codes; rejected values, exception data, and command fragments are never echoed to a client or crash report. Negative log/body-capture tests cover success, 4xx, and 5xx paths. A local-only user can continue to use FamilyProfile and one-person comparison, but cannot link another device/account until signed in.

The current local FamilyController is explicitly not the transport for this feature: it is a single process-wide document and is reachable under the desktop trust model. New linked-household endpoints must use tenant-session authentication, household authorization, CSRF/origin protections already required by the edition, rate limits, and household-namespace persistence through IHouseholdStore.

API shape

The API derives the caller from the trusted session. me endpoints make ownership hard to get wrong.

POST   /api/households
GET    /api/households
GET    /api/households/{householdId}
DELETE /api/households/{householdId}
POST   /api/households/{householdId}/deletion-requests
GET    /api/households/{householdId}/deletion-requests/current
DELETE /api/households/{householdId}/deletion-requests/current
POST   /api/households/{householdId}/deletion-requests/current/me/confirmation
DELETE /api/households/{householdId}/deletion-requests/current/me/confirmation
POST   /api/households/{householdId}/invitations
GET    /api/households/{householdId}/invitations
DELETE /api/households/{householdId}/invitations/{invitationId}
POST   /api/me/household-invitations/accept
POST   /api/me/household-invitations/decline
GET    /api/households/{householdId}/admissions
POST   /api/households/{householdId}/admissions/{admissionId}/me/confirmation
DELETE /api/households/{householdId}/admissions/{admissionId}/me/confirmation
GET    /api/me/household-admissions
GET    /api/me/household-admissions/{admissionId}
DELETE /api/me/household-admissions/{admissionId}
GET    /api/me/invitation-blocks
POST   /api/me/invitation-blocks
DELETE /api/me/invitation-blocks/{blockId}
PUT    /api/me/household-invitations/pause
DELETE /api/me/household-invitations/pause
DELETE /api/households/{householdId}/members/me
POST   /api/households/{householdId}/members/me/coordination
DELETE /api/households/{householdId}/members/me/coordination
POST   /api/households/{householdId}/coordination-election-requests
GET    /api/households/{householdId}/coordination-election-requests/current
POST   /api/households/{householdId}/coordination-election-requests/current/me/confirmation
DELETE /api/households/{householdId}/coordination-election-requests/current/me/confirmation
DELETE /api/households/{householdId}/coordination-election-requests/current
GET    /api/households/{householdId}/members
POST   /api/households/{householdId}/members/{membershipId}/permission-proposals
GET    /api/households/{householdId}/members/{membershipId}/permission-proposals
DELETE /api/households/{householdId}/members/{membershipId}/permission-proposals/{proposalId}
GET    /api/households/{householdId}/members/me/permission-proposals
POST   /api/households/{householdId}/members/me/permission-proposals/{proposalId}/accept
DELETE /api/households/{householdId}/members/me/permission-proposals/{proposalId}
PUT    /api/households/{householdId}/members/me/scenario-proposal-block
DELETE /api/households/{householdId}/members/me/scenario-proposal-block
POST   /api/households/{householdId}/members/me/permission-proposals/{proposalId}/block-source
GET    /api/me/permission-proposal-blocks
DELETE /api/me/permission-proposal-blocks/{blockId}
POST   /api/households/{householdId}/members/{membershipId}/coordination-challenges
GET    /api/households/{householdId}/coordination-challenges/current
DELETE /api/households/{householdId}/coordination-challenges/current
POST   /api/households/{householdId}/coordination-challenges/current/me/confirmation
DELETE /api/households/{householdId}/coordination-challenges/current/me/confirmation

GET    /api/households/{householdId}/scenarios
GET    /api/households/{householdId}/scenarios/{scenarioId}
PUT    /api/households/{householdId}/scenarios/{scenarioId}
POST   /api/households/{householdId}/scenarios/{scenarioId}/participation-offers/{membershipId}
GET    /api/households/{householdId}/members/me/scenario-offers
DELETE /api/households/{householdId}/members/me/scenario-offers/{offerId}
PUT    /api/me/scenario-offers/pause
DELETE /api/me/scenario-offers/pause
POST   /api/households/{householdId}/scenarios/{scenarioId}/participation-candidacy/me
DELETE /api/households/{householdId}/scenarios/{scenarioId}/participation-candidacy/me
GET    /api/households/{householdId}/scenarios/{scenarioId}/participation-preview/me
POST   /api/households/{householdId}/scenarios/{scenarioId}/visibility/me/confirmation
DELETE /api/households/{householdId}/scenarios/{scenarioId}/visibility/me/confirmation
DELETE /api/households/{householdId}/scenarios/{scenarioId}/visibility/me
GET    /api/households/{householdId}/scenarios/{scenarioId}/me/coordination-policy
PUT    /api/households/{householdId}/scenarios/{scenarioId}/me/coordination-policy
DELETE /api/households/{householdId}/scenarios/{scenarioId}/me/coordination-policy
GET    /api/households/{householdId}/scenarios/{scenarioId}/me/projections
GET    /api/households/{householdId}/scenarios/{scenarioId}/projections
GET    /api/households/{householdId}/scenarios/{scenarioId}/me/projections/{projectionId}/sharing-policy
PUT    /api/households/{householdId}/scenarios/{scenarioId}/me/projections/{projectionId}/sharing-policy
DELETE /api/households/{householdId}/scenarios/{scenarioId}/me/projections/{projectionId}/sharing-policy
DELETE /api/households/{householdId}/scenarios/{scenarioId}/me/projections/{projectionId}

POST   /api/me/relocation-drafts
GET    /api/me/relocation-drafts
GET    /api/me/relocation-drafts/{privateDraftId}
PUT    /api/me/relocation-drafts/{privateDraftId}
DELETE /api/me/relocation-drafts/{privateDraftId}
POST   /api/me/relocation-drafts/{privateDraftId}/publish
POST   /api/me/relocation-drafts/publish-ephemeral

POST   /api/me/move-intents
GET    /api/me/move-intents
GET    /api/me/move-intents/{privateMoveIntentId}
PUT    /api/me/move-intents/{privateMoveIntentId}
DELETE /api/me/move-intents/{privateMoveIntentId}
POST   /api/me/move-intents/{privateMoveIntentId}/publish
POST   /api/me/move-intents/publish-ephemeral

POST   /api/me/search-plans
GET    /api/me/search-plans
GET    /api/me/search-plans/{privateSearchPlanId}
PUT    /api/me/search-plans/{privateSearchPlanId}
DELETE /api/me/search-plans/{privateSearchPlanId}
POST   /api/me/search-plans/{privateSearchPlanId}/publish
POST   /api/me/search-plans/publish-ephemeral

POST   /api/me/employment-options/{privateEmploymentOptionId}/publish
POST   /api/me/employment-options/publish-ephemeral

GET    /api/households/{householdId}/scenarios/{scenarioId}/plans
POST   /api/households/{householdId}/scenarios/{scenarioId}/plans
GET    /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}
PUT    /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}
DELETE /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}
PUT    /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/me/employment-selection
DELETE /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/me/employment-selection
POST   /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/tax-units/{taxUnitId}/me/confirmation
DELETE /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/tax-units/{taxUnitId}/me/confirmation
POST   /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/benefits/{selectionId}/me/confirmation
DELETE /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/benefits/{selectionId}/me/confirmation
POST   /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/constraints/{constraintId}/me/confirmation
DELETE /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/constraints/{constraintId}/me/confirmation
POST   /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/composition/me/confirmation
DELETE /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/composition/me/confirmation
POST   /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/shared-costs/me/confirmation
DELETE /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/shared-costs/me/confirmation
POST   /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/relocation-costs/me/confirmation
DELETE /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/relocation-costs/me/confirmation
POST   /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/comparison-window/me/confirmation
DELETE /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/comparison-window/me/confirmation
POST   /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/one-time-costs/{costId}/me/confirmation
DELETE /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/one-time-costs/{costId}/me/confirmation
POST   /api/households/{householdId}/scenarios/{scenarioId}/plans/{planId}/evaluate
GET    /api/households/{householdId}/audit-summary
GET    /api/households/{householdId}/me/access-history

Invitation accept and decline intentionally take the opaque invitation token in the request body and do not expose a separately enumerable invitation id in the route. Both return the same generic response for unknown, expired, mismatched, blocked, already-used, and declined invitations. POST /api/me/invitation-blocks accepts only the opaque invitation token (or the server-issued decline continuation); the server derives inviter and canonical target claim. Subject, email, household, and inviter fields are rejected. The session subject must match the invitation target binding exactly as on accept/decline; mismatch returns the generic shape and writes no block. Every accept/decline response has the same shape and includes an opaque, single-use, short-TTL block continuation; only a valid decline continuation is actionable, while all others are indistinguishable non-redeemable values. Decline consumes the invitation code, and block-after-decline uses the continuation, never replay of the raw code. An accepted token returns the same generic receipt while creating a pending admission. Admission list responses are visible only to current adults required to confirm and to the candidate's self view; they contain only the signed validated display claim, coarse account-tenure band, eligibility-attested state, and generic ready/incomplete state, never contact, subject, inviter, per-member confirmation, or timestamp. Candidate self routes are /api/me only and cannot traverse into household resources. Final activation uses the active-set-bound all-adult quorum and the explicit empty HouseholdPermission grant described above.

POST /api/me/relocation-drafts/{privateDraftId}/publish and the explicit authenticated publish-ephemeral variant are the sole creators of a shared RelocationScenario; no household scenario-create payload endpoint exists. Later household PUT accepts only already-normalized shared fields, enforces the scenario visibility hash/confirmations, and cannot reference a private id. The typed employment-option publish route loads a private saved-job or offer source, while the separate ephemeral route accepts a typed unsynced value, then atomically creates the item projection and item-scoped policy through the same server factory. After creator-origin facts are stripped, this PUT cannot restore them; replacement requires private-draft publish to a new ScenarioId, and old plans remain bound to the incomplete shell until explicitly rebuilt. Scenario archival is a normalized PUT lifecycle proposal, not a hard-delete endpoint; it executes only after every current participant confirms the updated hash.

Synced first relocation-draft publish has the strict body { householdId }. Its ephemeral sibling has { householdId, ephemeralDraft }; only these relocation variants atomically create the scenario with the publisher as first participant/confirmation. Move-intent, search-plan, and employment-option publish routes instead require an existing public TargetScenarioId; their ephemeral variants add one closed typed private value and mint/replace only the kind-specific projection through the server factory. Owner, tenant, membership, actor, confirmation, private id, and extra fields are rejected on every variant. This removes multi-household ambiguity. The initial candidacy shell is created only by a confirmed participant's payload-free participation-offers/{membershipId} command and is visible only in the target's self inbox. The server-side offer stores scenario id/hash, while every notification queue carries only opaque prompt id and authorization epoch. Candidacy grants no payload; it creates a proposed-result hash shell visible only to current participants and that candidate. Current participants confirm first, then the candidate's strict-TTL preview route returns the normalized values; candidate confirmation commits the exact preview hash. DELETE .../visibility/me silently removes a current participant. A self-only personal setting may block future scenario proposals from that household without leaving it. If the caller originated the shared scenario facts, self-removal performs the same payload strip described for creator departure; current participants keep only a shell. The personal draft's outbound PublishedScenarioId is cleared/sealed; republish to that stripped shell is rejected even after rejoin. Replacement facts require a new ScenarioId, and old plans stay incomplete until explicitly rebuilt. The preview capability is bound to subject, current membership, scenario version, and content hash; it may be reread only during its short live window, always returns Cache-Control: no-store with no intermediary cache, and is hard-revoked on decline, expiry, hash change, or membership change. Its audit contains only opaque capability id, hash, and outcome—never scenario payload. Multi-read, expiry, cache, audit, and APM tests are release gates.

Every response containing household, admission, scenario, projection, plan, comparison, or personal publication data returns Cache-Control: private, no-store and Vary: Cookie, Authorization. Shared intermediary caches, validators that can rehydrate peer payloads, and BFCache restoration are forbidden; revoke-then-back/reload tests prove a live authorization check occurs.

Every /me/.../confirmation endpoint derives the subject and current membership from the trusted session, binds confirmation to the target resource version/content hash, and accepts no actor field. Its strict request body is { resourceVersion, contentHash }; stale values return conflict and unknown or extra fields are rejected. The shared-costs and relocation-costs confirmation routes bind their entire versioned collection and content hash, not an individual row; every affected adult sees the same normalized collection before confirming it. The member-owned employment-selection PUT similarly creates a new version/hash and records only that member's server-derived confirmation. Its strict body is { employmentState, projectionId?, projectionRevision?, projectionContentDigest? }: pin-required states require a live owned option projection of the matrix-required kind in the exact path scenario, with matching id, revision, and digest; state-only selections reject all three pin fields. The server derives member/hash and rejects actor, confirmation, or extra fields. DELETE withdraws it. Household deletion confirmations bind the active membership set and deletion-request version; any membership change clears them. POST deletion-requests creates a versioned proposal, GET .../current returns its payload-free state to active members, and DELETE .../current cancels it without deleting the household. Cancellation is always available to any active member; recreating a canceled or expired request is subject to a household-wide cooldown so repeated prompts cannot become coercive. DELETE /api/households/{householdId} executes only a still-current request with the required confirmations (or the documented sole-member case). Coordination self-election is available only to a sole remaining member. Any active adult may create an election request only while the realm is unmanaged. With multiple active adults, the versioned election request binds the active membership set and executes only after every remaining adult confirms; membership change clears it. Any active adult may silently cancel a pending election while unmanaged; cancel/recreate is version-bound, cooled down, and exposes no actor or withdrawal detail. The role grants least-privilege coordination, not access to peer data; a coordinator may step down without leaving, and the realm may safely return to unmanaged state.

A coordinator challenge may be started by any active adult other than the target while the target still holds coordination permissions. It binds the target, active membership set, target permission revision, and expiry in a payload-free content hash. Every other active adult except the target must confirm; the target cannot confirm, veto, withdraw confirmations, cancel, or learn who acted. Any eligible confirmer may cancel the whole pending challenge. Success atomically strips every member's coordination permissions, preserves membership and every resource-owner right, bumps the household authorization epoch, and always leaves the realm unmanaged while minting new audience snapshots; a two-adult challenger can reset governance but cannot seize a monopoly. While a challenge is pending, invitation activation, permission expansion, election, deletion, and other adult-set/coordination changes are rejected. Leave, self scenario removal, resource-owner revoke/delete, blocks, and rate-limited issue of an empty-grant support invitation remain available. A non-target leave rebinds the challenge to the new set and clears confirmations; target leave or completed relinquishment makes it moot. Target permission expansion is rejected rather than invalidating the challenge. A challenge expires automatically within one hour and the same proposer cannot chain another challenge for seven days after completion, cancellation, or expiry; creation is also rate-limited.

The /api/me/relocation-drafts, /api/me/move-intents, and /api/me/search-plans handlers use personal tenant storage and address only private ids after the initial scenario-bound create command; they never write the private draft, intent, or search plan to the household store or household audit. Publishing is a separate authenticated server operation that emits only a normalized household scenario/projection, records its public ScenarioId only in the owner-side draft, and never copies a private id into the household realm. An assembly/reference test prevents the household application layer from referencing personal draft/search repositories; the only cross-boundary port accepts the authenticated owner plus a projection request and returns the normalized household DTO. Move-intent and search-plan create DTOs may carry only the public TargetScenarioId plus private input; they carry no owner, tenant, household, membership, projection, or confirmation fields. The personal handler derives owner/tenant, resolves the scenario's household server-side, and verifies participation before persisting the private record. Creating and editing a private draft requires no household grant. The publish command proves the caller has active membership in the target household; publishing again to an existing PublishedScenarioId also requires the caller's confirmation to match the pre-write ScenarioVisibility version/content hash and any applicable EditScenario grant. Kind-scoped SharingPolicy applies only to projections. Republish is the same staged scenario-value mutation as household PUT: it mints a new version/hash, binds the writer to the proposed post-write hash, gives every other participant a self-only reconfirmation shell, and commits the new payload only after all confirm. Creating a move intent or search plan for a public TargetScenarioId requires a current participation grant whose caller confirmation matches the live scenario hash at creation; later owner-only reads, edits, or deletion use the private id and personal authorization. The owner may continue to read, update, or stop an already-owned private draft, criteria, search, or application after leaving or after scenario archival; that authorization is based on the personal record's tenant and owner, not a surviving household grant. Any later publish requires a new active participation grant and live policy.

The accept or decline token is sent in the POST body, never a path or query. Acceptance requires an authenticated session that matches the immutable existing-subject binding and has a current trusted adult-eligibility claim. MVP delivery uses a 12-character, uniformly random Base32 one-time code (60 bits of entropy) that the recipient types into a generic signed-in acceptance page; email links may open only that token-free generic page and never carry the code, invitation id, token, or an exchange bearer. The endpoint returns Cache-Control: no-store and a restrictive referrer policy. Raw invitation tokens must not appear in URLs, browser history, structured logs, proxy/WAF/APM fields, errors, traces, analytics, or telemetry. Redaction and negative log-capture tests are release gates. The code is stored only as a server-peppered, domain-separated HMAC lookup key, not a self-verifying bearer; redemption locks the matching server-side invitation row before checking its binding. Redemption is one-use, expires after seven days, is rate-limited, and is invalid after invitation cancellation or household deletion. The authenticated server-side invitation record cryptographically binds the existing target subject to the household id; the typed code never selects a caller-supplied household. Code validation, current block/cooldown/membership checks, token invalidation, empty-permission pending-admission creation, and identity-mapping reservation execute once in a serializable transaction with a locked invitation row. Any membership status change tombstones all outstanding target tokens.

The inviter sees only Sent, Expired, Cancelled, or the eventual accepted membership. There are no viewed/read receipts, decline signals, delivery diagnostics, or account-existence differences. A recipient can silently decline or block future invitations from that inviter without notifying them. Invitation creation returns the same shape and timing whether the target account exists, is already a member, is blocked, or cannot receive an MVP invite. Independent pending caps, cooldowns, and rate limiters by inviter subject, inviter-target pair, and household-target pair must pass. A global target budget may add soft delay, but a recipient-side delivery budget hard-caps real invitations at three per rolling 30 days across all distinct inviter subjects/households. The recipient may pause all household invitations indefinitely; excess/paused requests still create identical inviter-visible shadows. Account age/reputation may further restrict real delivery but never change inviter shape. Creation IP limits remain an abuse signal, not the sole decision. Redemption has hard ceilings of 20 attempts per authenticated subject per hour, 50 per day, 100 per IP per hour, and five binding mismatches per valid invitation before invalidation, all with generic errors and exponential backoff. A recipient block covers the inviter subject and originating household, not one composite key. Silent decline, block, or explicit recipient discard may restore the recipient's real-delivery allowance so spam cannot crowd out a later legitimate invite; it never releases the inviter's throttle lease or changes inviter-visible behavior before nominal expiry. Decline neither frees inviter capacity nor changes inviter-visible state before expiry. Delivery copy contains no destination, installation, military context, or move window. Every constant-shape outcome materializes the same inviter-visible shadow lifecycle and queues the same generic worker job. The worker performs a constant-observable no-op for blocked, paused, capped, non-receivable, or already-member targets; only eligible recipients receive email/in-app delivery, and no bounce/delivery diagnostic returns to the inviter. List shape and externally observable timing remain indistinguishable.

Permission proposals likewise have a per-target pending cap, create cooldown/rate limit, automatic expiry, and in-app-only delivery. Decline remains proposer-silent: proposer-visible state stays Pending until expiry and exposes no timestamp or row removal. Blocking from a proposal derives and stores (householdId, blockerSubjectId, blockedSubjectId) server-side, never a client membership id; it survives leave/rejoin and the new membership epoch. Scenario participation offers use the same abuse posture: at most one open offer per (ScenarioId, TargetMembershipId), automatic expiry, proposer/proposer-target/household-target rate limits, a decline/expiry cooldown, proposer-silent outcomes, and target-controlled per-household/global pause. Offer blocks survive leave/rejoin and no notification template identifies the scenario or proposer.

Comparison semantics

A comparison is a set of named household plans, not an optimizer over every combination of every member's jobs. Members explicitly select at most one employment state or option per plan. This avoids a Cartesian explosion and, more importantly, avoids quietly deciding which adult's career to favor.

Each plan renders the following layers separately:

  1. Authorized employment rows: only live policy/audience-normalized continuing-role or selected-option evidence; unavailable rows are omitted and there are no per-adult missing flags.
  2. Household tax: confirmed units are calculated without double counting, but the peer result is one disclosure-monotone household row with no filer/member breakdown.
  3. Shared recurring costs: housing, groceries, childcare, school, transportation, insurance, and other entered costs once per household, with current-vs-destination evidence.
  4. Benefits coverage: category, closed non-identifying coverage scope, scenario-capped window, and optional premium. Member allocations remain visible only to affected confirmers; comparison rows never carry membership ids. Do not sum employer benefit values when plans overlap or treat an unpriced benefit as cash.
  5. One-time relocation: moving costs, deposits, house-hunting travel, license transfer, and employer or military reimbursement. Assistance is itemized so covered costs are not counted twice.
  6. Transition timeline: household cash-flow evidence bucketed no finer than the least-permissive scenario/comparison/contributor time grant, with no member legs or attributed gap labels. If a supported bucket cannot be produced without refining a grant, the timeline is incomplete.
  7. Hard constraints: must-move date/window, location, childcare availability, license feasibility, remote/OCONUS authorization, minimum cash runway, and other member-confirmed dealbreakers.

The canonical cash stack assembles confirmed gross income, taxable assistance, supported pre-tax deduction candidates, and tax-credit bases into one supported multi-earner calculation per confirmed tax unit. It sums those unit liabilities once, then applies confirmed post-tax benefit premiums, shared costs, relocation flows, and one-time costs/assistance exactly once at household level. Benefit premium is the canonical source for employer-plan premiums; mutually exclusive category rules reject the same insurance outflow in shared costs. Missing or unsupported tax treatment leaves the result incomplete; an unpriced benefit never enters cash math. A personal take-home estimate is not a household-plan input and is never subtracted alongside household tax. Regression tests prove joint tax and shared costs are applied exactly once across multiple units, and benefit-premium/shared-insurance duplication fails. Time-bucket bounds are conservative envelopes computed only with declared monotone operators or an interval/constraint method proven against the calculator's credits, phaseouts, filing status, and jurisdiction fixtures. A simple corner product is forbidden when the tax function is non-monotone; unsupported/non-proven cases remain incomplete. Bounds are labeled bounds, never expected values or midpoint forecasts. Shared plan mutation DTOs never accept employment selections, projection pins, actor, or confirmation maps; they propose only non-employment shared subresources. Projection pins exist solely on the owner-only /me/employment-selection route and cannot supply or override money, timing, benefits, policy, or payload.

The result may highlight a constraint violation or incomplete evidence. It must not emit a composite score, “best family member,” auto-selected plan, or recommendation that hides the inputs.

Plans belong to the household coordination realm and record a creator plus optimistic version; no creator owns another member's input. Shared assumptions show the caller's own confirmation and generic readiness, not another member's proposer/confirmation roster. A member's projection is referenced by id/revision rather than embedded. When that member leaves or revokes, the plan may persist only as an incomplete shell and cannot show the old value. Ordinary roster and per-member row changes can still reveal who left or reduced sharing; the product warns about that state channel before first share and never promises anonymity it cannot provide.

Flow and Studio surfaces

Every implementation slice must ship both modes.

Flow is a calm guided path:

  1. “Who is planning this move with you?”
  2. Invite adult accounts; clearly state that acceptance shares no career data.
  3. The creator privately enters “Where might the household move?”; no other adult sees region, installation, or window until the staged offer → current-participant confirmation → target preview → target confirmation flow completes.
  4. Only a confirmed participant gets their own “Are you moving, and do you want a search here?” task; candidate prompts are payload-free.
  5. A household plan page shows only authorized normalized rows and selected options, plus one generic complete/incomplete status—never a named member participation or missing-input list.

Studio exposes scenario versions, policy fields, disclosure levels, search assumptions, tax units, benefit coverage, cost evidence, timeline detail, the coarse shared audit-summary, and the signed-in member's self-only access-history. It never renders a peer action/timestamp audit log.

Adult partners and adult children are called members, never dependents. Relationship labels are optional display context and grant no permission. Invitations, revocation, and leaving must be fully keyboard accessible, screen-reader explicit about the data affected, and usable without revealing a member's private inputs to the inviter.

Threat model and privacy failures to prevent

Threat Required control
Controlling partner uses “family” access to monitor another adult Independent identity, no implicit sharing, owner-only writes, immediate leave/revoke, projection purge, self-only detailed access history, no proactive peer notification of reduced sharing/exit, and an honest warning that later roster/member-row reads can reveal the change
Salary inferred from an aggregate, joint tax, timeline, or plan delta Disclosure-monotone derived outputs; exact only with exact consent from every contributor; interval/incomplete otherwise; suppress isolate-by-difference views; property tests
Invitation forwarded, guessed, replayed, or leaked by infrastructure Immutable-subject binding, body-only token, keyed hash at rest, short expiry, one use, cancellation, rate limit, generic error copy, end-to-end log-capture tests
Invitation becomes an account oracle or harassment loop Constant-shape create response, no read/decline receipts, pending caps and inviter/target/household/IP rate partitions, recipient block, cooldown, former-member-controlled rejoin
Household id or member id swapped in an API request Trusted-session subject derivation, membership authorization on every resource, opaque ids, negative cross-household tests
Revoked data survives in plans/caches Plans store ids/revisions rather than payload snapshots; synchronous tombstone, deterministic invalidation/recompute, cache purge, retention test, generic unattributed incomplete status
A peer client retains a fetched projection after revocation No durable/offline peer-projection cache; short-lived memory only, payload-free authorization-epoch invalidation, purge on reconnect/resume, honest warning that already-viewed or copied values cannot be erased
Exact PCS movements leak through reads, logs, or notifications Creator-private draft, participant visibility grant, typed region/window precision, payload redaction, no exact dates/addresses in peer notifications or telemetry
Military-spouse eligibility leaks to other members or employers Eligibility stays personal; shared output is an owner-chosen feasibility/resource status, never documents or basis
One member triggers applications for another Household APIs cannot call apply services; search activation and every application remain personal actions
Adult count is mistaken for accepted membership or double-counted with linked adults Separate models/stores; no migration or automatic subtraction; user-confirmed cost composition names which linked members and anonymous people the assumptions cover
Personal tenant and household realm are confused Separate typed ids and service boundaries; no generic cross-scope repository; compile-time and integration tests

Because this feature introduces cross-account authorization, sensitive financial projections, and possible coercive-control risk, implementation is security-sensitive. It requires the full security review seats and negative authorization tests before release. Automated gates must prove that a household context cannot read a personal store, client-supplied owner/tenant ids are rejected, every resource/verb defaults deny, revoked revisions cannot reappear, and derived financial outputs do not exceed the least-permissive input. Active testing still requires separate human authorization for a named isolated target and time window.

Testing strategy

  • Unit/property tests: projection field masks, typed money interval laws, disclosure monotonicity, tax-once semantics, scenario-wide geography/time precision caps, field-specific free-text smuggling rejection, quantization, and every confirmation content hash/version transition.
  • Authorization integration tests: two personal tenants and two households exercise every resource/verb, nested-id swap, forged actor/permission/confirmation field, departed membership, and the compile-time personal-store/household-store reference guard. Tests pin the creator's exact bootstrap permissions, deny cross-subject task creation, require current scenario confirmation on every payload path, hide participation proposals/confirmation maps, reject wrong-shape peer DTOs, and reject stale confirmation bodies. Pending candidates can reach only their three /api/me admission verbs. Audience- expansion and unmanaged-recovery tests prove no older projection or derived comparison becomes readable until its owner grants the exact current snapshot. Cross-scenario employment-pin body swaps fail.
  • Concurrency/state tests: simultaneous invitation redemption, cancel-vs-accept, revoke-vs-publish, leave-vs-plan-evaluate, multi-node cache invalidation, reconnect queues, rejoin under a new membership id, candidate-preview target-only acceptance, global cross-household admission reservation, admission packing and all-current-adult quorum/attestation hash changes, cancel-after-decline throttle retention, coordinator challenge/election races, unmanaged read suspension/recovery, and account-delete across multiple memberships.
  • Persistence/observability tests: realm dumps, indexes, exports when present, caches, logs, traces, metrics, notifications, and analytics contain neither revoked payloads nor forbidden personal/OPSEC fields. Invalidation payloads contain only opaque household id plus authorization epoch, and peer member lists contain no departed row, timestamp, or tombstone. Invite shadow list/delivery behavior is indistinguishable for valid, blocked, already-member, and nonexistent targets; the same throttle/dedupe capacity remains consumed through nominal expiry; preview capabilities never enter intermediary caches or payload-bearing audit/APM; sensitive HTTP responses are private/no-store and cannot return through BFCache/back; generic notification templates contain no scenario/member/money data; and ephemeral publish bodies never appear.
  • End-to-end tests: Flow and Studio cover invitation silence, independent member actions, range/exact/unknown displays, caller-specific comparison materializations, second-device revoke/reload, resume/reconnect purge, unmanaged coordinator recovery, candidacy-offer flood resistance, keyboard and screen-reader behavior, and the honest roster/member-row residual-inference warning for every household size.
  • Mutation/invariant tests: reducing a field mask without hard-null stripping fails; precision reductions requantize or drop; a target alone cannot commit candidacy; a preview decline preserves only the warned residual; invitation and permission blocks survive rejoin; duplicate active membership, throttle keys, and pending admissions fail; unmanaged resource owners can revoke; a client DTO cannot shrink a live confirmer set; revoked projection ids cannot be reused; and member self-removal clears every scenario/plan pin before revoke-then-reload/regenerate.

Lifecycle and deletion

  • Invitation canceled/expired: token becomes unusable; no membership or sharing is created.
  • Policy revoked or reduced: before server acknowledgement, synchronously payload-strip/mask every affected household projection using only already-normalized household values; the household path never reads a personal store or derives a range from a prior exact point. Any field needing private truth becomes typed unknown/omitted until a new owner /api/me publish. Full revoke hard-deletes the active projection and leaves only its payload-free tombstone; reduction writes one stripped live revision and no same-id tombstone twin. Tombstone superseded revisions and strip dependent comparison/cache/export payloads, and mark plans incomplete. There is no proactive peer push/email/audit event, but later authorized member-row reads may reveal whose value changed. New reads exclude anything outside the reduced policy. The initiating device immediately purges its local in-memory household view. An offline UI says the server action is pending, never “complete,” and honestly states that other devices retain server access until acknowledgement. Revoke/leave intent preempts queued publishes, and every dequeued or late publish rechecks active membership and live policy before the server drops or accepts it.
  • Member leaves or the account is administratively closed: the pending state is visible only to the departing member's subject and devices; administrative tooling may hold separate operator status that is never a household signal. Delete all member-owned projections and policies from the household, synchronously tombstone their revisions, invalidate plan results, purge caches, suppress peer push/ email notifications about the exit or sharing reduction. Shared audit contains no exit, decline, reduction, membership-changed marker, or timestamp; any leave event is self-only detailed history or non-attributable operations retention. A user warning must be honest: revocation stops future access but cannot erase values another person already saw or recorded. The purge also removes or tombstones all membership-scoped references in visibility grants, confirmations, plan creators, shared-cost and one-time-cost proposers, tax units, benefit coverage, composition, and employment selections. Internal plan storage may use a payload-free departed-member tombstone, but peer DTOs expose only generic PlanInputUnavailable and become incomplete; a realm-dump test must find no global subject id or active membership reference for the departed member. For a departure-only visibility mutation, the transaction removes the departed reference and recomputes ScenarioVisibility.ContentHash; it may rebind surviving confirmations only when the removed participant is the sole hash delta. Every affected plan is rewritten as a new version: member references and pins are removed, all sibling content hashes are recomputed, the evaluation pin is cleared, and survivor confirmations are cleared wherever the referenced-member set or value changed. Archive only when the post-leave Participants set is empty, never merely because a confirmation map was invalidated. A creator-private relocation draft remains personal. On creator departure, the transaction replaces CreatedBy with DepartedResourceCreatorTombstone and strips the creator-originated geography, window, reason, lifecycle, and CONUS context to unknown/omitted. Remaining participants keep a shell, their own projections, and incomplete plans. Because creator stripping changes more than the participant set, it clears every scenario and affected plan confirmation; confirmation rebinding is forbidden. An active member must publish a new ScenarioId from their own private draft before those facts can return. This prevents ongoing redisclosure or participant expansion of a departed person's PCS facts. The departing creator's personal draft clears/seals its old outbound PublishedScenarioId; republish to the stripped shell always fails. Peer scenario/plan DTOs never carry a departed membership id. If no participant remains, archive and apply the cascade below.
  • Member removes self from a scenario: in one transaction, remove the participant reference, bump the household authorization epoch, tombstone that member's scenario-bound projections and policies, clear every employment pin and plan reference to them, rewrite affected plans with new versions/hashes and cleared confirmations, purge comparison/export/client-notification caches, and return only a generic incomplete shell to survivors. If the member originated the scenario facts, apply the creator-strip/new- ScenarioId rule above. This action never stops the member's private search or removes household membership, and no peer notification or attributed audit event is emitted. If the post-removal participant set is empty, the same transaction performs the system archive/purge cascade below without requiring an impossible participant confirmation.
  • Scenario archived: stop household coordination jobs, published status, and household notifications tied to it. Atomically tombstone or purge every scenario-bound MemberCoordinationProjection, SharingPolicy, comparison payload, and cached/exported household view; dependent plan shells become generically incomplete. Any retained archive marker contains only ids, generation, and lifecycle state; it has no geography, window, member, policy, projection, money, or reason payload. A member's private criteria, searches, and applications continue until that member stops them; archival may prompt but never issue a cross-account stop. Archival must not freeze a revoked projection into a comparison snapshot.
  • Household deleted: purge the household realm. Personal accounts and records are untouched.
  • Account deleted: execute the complete acknowledged member-leave transaction independently for every active household membership before applying personal retention. Remove all memberships/projections, epoch-bound queues, pins, confirmations, and subject-bearing references; make surviving plans incomplete rather than reassigning ownership. Partial multi-household completion is retried idempotently and the account is not reported deleted while a household leave cascade remains pending.
  • Last coordinator leaves: exit still succeeds. The realm becomes unmanaged and mints new audience snapshots for every live scenario. All peer projection/plan/comparison/share/export reads and unlisted coordinator verbs pause. Always available are leave, self views, resource-owner policy reduce/revoke and projection delete, first-publish of a new self-owned scenario, self scenario removal, blocks, election, and rate-limited empty-grant invitation issue; invitation creators may list/cancel only their own redacted rows, and admission activation still requires every current adult. A sole remaining adult may self-elect; with multiple adults, the version-bound election requires every remaining adult's confirmation. No prior coordinator grant is required. Archive/purge only when no active member remains; never destroy other members' shared plan merely to preserve coordination continuity.
  • Former member rejoin: only the former member can accept a new invitation. A recipient block and cooldown prevent remove/re-invite loops; a new membership id is always created and no old token, visibility grant, policy, projection, or confirmation reattaches.

HouseholdComparison is an on-demand result, not a durable snapshot. Persist the plan, projection references, input digest, and calculator version; keep at most a short-lived authorization-aware memory cache keyed by HouseholdId, ScenarioId, HouseholdPlanId, caller MembershipId, HouseholdAuthorizationEpoch, complete input digest, and calculator version. It never caches one caller's DTO for another. The epoch is a monotonic household counter advanced by every authorization-affecting transition: pending-admission create/withdraw/expire/activation and membership leave/close; permission grant/revoke/challenge/election and unmanaged-state entry/recovery; policy create/expand/reduce/revoke; audience-generation mint/re-grant; projection publish/republish/tombstone; scenario candidacy commit, participation removal, visibility/value mutation, or archive; and household deletion. Before returning a cache hit, evaluate reruns the complete live authorization path—including active membership, scenario participation/hash, plan/resource confirmations, live policies, projection revisions, field masks, precision, and input digest—against the authoritative store. A distributed invalidation event accelerates eviction but is never the authorization control. Its payload contains only opaque HouseholdId plus AuthorizationEpoch, never a scenario, member, projection, field, or reason. Export/offline generation reruns the same live authorization and scenario disclosure fingerprint as evaluate and cannot retain any money, tax, timeline, delta, or member row that exceeds current least-permissive consent, even if labels are removed. Dual-device/offline races, and revoke-then-reload are projection/comparison release tests; revoke-then-export becomes a gate only for a slice that introduces export. Every output materialization—including evaluate, cache refill, export, offline regeneration, print, share, accessibility snapshot, and notification resolution—must pass the same revoke/reload/regenerate gate and must never fall back to a previously authorized result. Clients purge displayed peer payloads on lock, background, resume/reconnect, authorization-epoch change, failed reauthorization, sign-out, household switch, and safe-exit initiation. “Share” means a live, authenticated, membership-bound fetch: there is no public or durable payload link, every open reruns evaluate authorization, and policy revoke, scenario removal, leave, or unmanaged-state entry invalidates the fetch immediately.

Phased implementation backlog

Each phase is a vertical slice with Flow, Studio, accessibility, tenant-isolation, and revocation tests. Phase 1 production implementation begins only after Gate 1 succeeds and Gate 2 meets consent- comprehension, task, usefulness, fail-closed, and safety-credit conditions. A dated scope-limited waiver may authorize internal learning work, but cannot waive fail-closed safety items or specialist sign-off, cannot label the gate “safety passed,” and cannot authorize external production release. Evidence gates and technical prerequisites are cumulative, not substitutes. Before phase 1 starts, the team must also resolve and test the stable desktop/cloud subject transport, final least-privilege permission schema, tenant-scoped private stores, server-acknowledged safe-exit protocol, and notification/audit contract. No email-hash identity, process-wide store, implicit coordinator, or optimistic offline success is an acceptable temporary fallback.

  1. Identity and consent slice — household create/invite/accept/leave, membership permissions, body-only token redemption, abuse/rate limits, log/notification redaction, safe exit, coarse shared audit plus self-only access history, and a zero-data-shared default. Dependencies: stable signed-in subject on desktop and cloud, plus tenant-scoped persistence for every private service touched by projection building.
  2. Relocation coordination slice — versioned destination/installation scenario, lifecycle, member move intent, and member-owned destination search plan activation.
  3. Projection slice — server-side selected job/offer projection factory, disclosure policy, owner-only publish/revoke, synchronous tombstone/cache purge, range-preserving money evidence, disclosure-monotonicity tests, and stale-plan invalidation.
  4. Household comparison slice — explicit plans, tax units, shared costs, benefit coverage, relocation assistance, timeline, hard constraints, and no-composite-score guard.
  5. Military-family depth slice — spouse resource handoffs, private USAJOBS eligibility/document checklist, licensure portability evidence, installation geography, and domestic/OCONUS remote states.
  6. Operational hardening — retention jobs, export/delete, review-evidence packaging, and release revalidation. Foundational auth, invitation abuse controls, revocation, redaction, and inference protection are part of the earlier slices and cannot be deferred here. Security risk is reassessed at phase 1, phase 3, phase 5, and phase 6/release-candidate boundaries. If active testing is warranted at any boundary, the team must request Andrew's fresh explicit authorization for the named isolated target, scope, and time window. A prior phase's authorization never carries forward or supplies retroactive authorization for release testing.

Separately from human Gate 2, every implementation slice has an automated engineering security gate. It attempts cross-household/nested ids, forged owner and confirmation fields, stale membership ids/revisions, multi-session plan differencing, queued-publish-after-revoke, and revoke-then-reload. A slice that adds export also adds revoke-then-export. These are synthetic, isolated automated tests, not active penetration testing.

Evidence plan

Decision

Whether to build the production linked-household feature and which sharing defaults are acceptable.

Riskiest assumption

Recent-PCS, multi-income households will coordinate better with linked but independent accounts, and enough adults will knowingly share job-stage and financial projections for the combined comparison to be useful.

No customer observation has yet been recorded for this claim.

Gate 1 — behavioral discovery

  • Segment: eight U.S. households that completed a PCS in the previous 24 months and had at least two working or job-seeking adults; include trailing spouses and, where present, adult children.
  • Method: interview adults separately before showing the solution. Reconstruct the last move from orders/destination awareness through employment and housing decisions. Ask what each person searched, shared, withheld, and compared; inspect an existing planning artifact only with explicit consent.
  • Primary metric: number of households in which at least two adults independently performed an employment decision around the same move and used a shared artifact or repeated manual exchange to compare household timing or money.
  • Success: at least 5 of 8 households meet the primary behavior.
  • Failure: 2 or fewer meet it. Three or four is inconclusive; within this spike's resource cap the decision is pivot/stop, unless Andrew explicitly authorizes a separately preregistered follow-up.
  • Guardrails: no orders, SSNs, addresses, employer credentials, or eligibility documents collected; separate consent; one partner never receives the other's answers; participants may skip any field.
  • Confounders to record: branch/service/community resources, occupation portability, remote-work status, housing urgency, household size, time since PCS, prior use of Military OneSource, and whether the artifact was required by an employer or lender rather than chosen for coordination.

Run only if Gate 1 succeeds or produces a specifically authorized follow-up.

  • Segment: six recent-PCS, multi-income households meeting Gate 1's inclusion criteria (twelve or more adults); Gate 1 participants may join only through a fresh, independent prototype consent.
  • Method: they use synthetic job/offer data in a clickable Flow prototype. Each adult accepts independently, confirms a pending admission, chooses range sharing, expands a scenario audience, builds a plan, activates their own search, publishes an option, revokes one field, reloads on a second simulated device, completes a safe-exit simulation, and interprets the result.
  • Consent-comprehension metric: at least 80% of participating adults (minimum 10) correctly answer what membership shares, what it never shares, why adding a participant does not unlock previously shared projections, who can change a projection, and what revocation removes.
  • Task metric: at least 80% of participating households (minimum 5) complete one comparison without any member entering or controlling another member's private data.
  • Usefulness metric: at least 4 of 6 identify a concrete household tradeoff they had previously tracked manually, using the displayed inputs rather than a hypothetical complement.
  • Fail closed: any participant reasonably believes invitation acceptance exposes resumes, applications, credentials, exact PCS details, or military eligibility; any member cannot leave or revoke without partner approval; a reduced-sharing or safe-exit action is announced to a peer; stale values appear in the simulated revoke/reload flow; or taxes, timelines, aggregates, plan differences, or another displayed output reveal more precision than the member selected.
  • Safety-credit blocker: before Gate 2 can be reported as “safety passed,” an IPV/coercive-control specialist and a military-family/OPSEC advisor must review the flow. The first-share screen must state for every household size that roster and per-member row changes may identify who left or reduced sharing even without a notification.
  • Confounders to record: prior technical fluency, familiarity with consent controls, whether adults test together or separately, household size, relationship power dynamics participants volunteer, prototype latency, and whether synthetic numbers resemble the household's real situation.
  • Window/resource limit: one discovery cohort and one prototype iteration, reviewed within 30 days. If thresholds are missed, iterate once only on a named comprehension/usability defect; otherwise pivot or stop rather than repeat inconclusive tests indefinitely.

Current action

Gather the specified evidence. The architecture is coherent enough to prototype, but production work should not be presented as validated demand.

Open decisions before production

These decisions may select a stricter validated implementation; they may not weaken independent consent, owner-only writes, immediate server-side authorization cutoff, silent exit/reduction notifications, typed precision, or disclosure-monotone output. Any proposal to relax those controls requires a new threat model, specialist review, and the full security gate before it can replace this contract.

  • Which stable sign-in/subject mechanism is available to desktop users, and which transport/retry design meets the defined pending-state, preemption, acknowledgement, and reconnect-purge contract?
  • Can one adult belong to multiple households, and how are separated/co-parenting households handled?
  • After evidence from the empty-grant prototype, which narrowly scoped coordination permissions, if any, should an adult be able to request and independently accept without creating an “account owner”?
  • What exact payload-free audit history is useful to members, and what retention period is justified?
  • Which location detail is required for useful search while keeping PCS movements safe by default?
  • Which tax, benefits, childcare, housing, military allowance, and relocation-assistance inputs are authoritative enough for the first comparison slice?
  • What safe-exit and notification behavior is required after consultation with people experienced in intimate-partner abuse and military-family privacy?

Official workflow sources

These sources support workflow constraints, not demand for linked accounts.