Skip to main content
Wildo.ai Coming soon

Audit trails & data privacy

Record accountable actions and handle personal-data requests, retention and erasure through application services.

Audit history · subject export · retention · erasure · security-event export

> From access rules to accountable actions > From personal data to rights and retention > From product behavior to supported commitments

Compliance begins with how an application handles people, information and responsibility. Audit makes important actions traceable; assurance connects commitments to the evidence needed to evaluate them.

Wildo brings these concerns into the same application model: access controls, data treatment, event history and the facts behind your documents.

You define the operating commitments. The product supplies a basis for explaining and checking them.

Application rules connect access and personal data to audit evidence and reviewed commitments.

Make what you say follow what the product does

Protect information through its lifecycle

Connect identity and access to the records people use. Declare personal-data treatment, answer subject requests and carry erasure into attachments and connected services, with explicit rules for what must remain.

Make important actions explainable

Record the actor, scope and meaningful change. Give customers access to their history and connect selected events to their security tooling, so evidence remains useful beyond the action itself.

Ground commitments in evidence

Connect control objectives to implementation and observations. Build notices and governance documents from product facts, operating context and reviewed decisions, preserving the source behind each statement.

Example: Explain what happens when a customer leaves

The application handles a customer’s departure by changing workspace access and processing the relevant personal-data requests. These are separate actions: closing a workspace does not automatically erase every member’s account. Their outcomes and audit records give a review concrete evidence to compare with the departure process described in the notice.

For engineers

Begin with the product’s own model

Field categories describe the information held. Resource policies govern export and erasure participation; relationships determine the records involved. These are distinct declarations because they drive different behavior.

The companion materializes application and engine resource configurations into document facts. It retains the names of configurations that cannot materialize, so a missing resource cannot silently become a claim that the application holds less information. Declaration-presence flags such as declaresSubjectExport distinguish a recorded choice from an omission; an explicit refusal is still a choice, not permission.

Follow one commitment from configuration into a clause

This actual Wonder CRM setting supplies an explicit answer under operatorComplianceIdentity in wildo.saas.config.ts:

serviceConsentRequirement: {
  requiredPurposes: [],
},

The companion’s document brief renders that answer as a declared absence of a service-wide consent gate. An absent setting instead remains unanswered. The document-fact declaration records the same meaning; these are selected fields from Wonder CRM’s document-facts family:

"factRef": "document-fact:service-consent-gate",
"resolutionKind": ApplicationAssuranceDocumentFactContributionResolutionKind.KNOWN,
"value": "Use of the service is not gated on consent. This is a service-wide consent-gate configuration and does not state a lawful basis for processing."

The governed-document candidate then binds its explanation to that exact reference. Selected fields from the corresponding clause:

"clauseRef": "clause:service-wide-consent-gate",
"bodyMarkdown": "Use of Wonder CRM is not gated on service-wide consent. This configured gate status does not identify a lawful basis for processing.",
"factRefs": ["document-fact:service-consent-gate"]

This is a connected authoring example, not a claim that the renderer automatically certifies a notice. The brief supplies configuration context; the typed fact and clause retain the authored statement and its citation. Review must establish that their meaning agrees. Other operator choices, processing arrangements and jurisdictions belong in their own declarations rather than being guessed while writing.

Ask for the evidence that answers the question

A configured control and a recorded occurrence support different conclusions. Each assurance requirement names its obligation, evidence home, execution plane and verification steps. The programme resolves its framework contributions against the application’s enabled capabilities and its own artifacts.

For example, where the audit-trail capability is enabled, the security-event requirement names an emitted record in the production plane. These selected fields are from assurance-evidence-requirement-bodies.definitions.ts; the rest of the event population and the requirement’s verification instructions are omitted:

{
  clauseRef: "C1",
  obligation:
    "Authentication, authorization, privilege, credential and security-configuration changes each emit a typed auditable event.",
  home: AssuranceEvidenceHome.EMITTED,
  plane: AssuranceEvidenceExecutionPlane.PRODUCTION,
  requiredAuditEventMembers: [
    CoreAuditableEventType.USER_LOGIN,
    CoreAuditableEventType.USER_LOGIN_FAILED,
    CoreAuditableEventType.USER_LOGOUT,
    CoreAuditableEventType.USER_SESSION_EVICTED,
    CoreAuditableEventType.RESOURCE_OPERATION_DENIED,
    CoreAuditableEventType.USER_APP_ROLES_CHANGED,
    CoreAuditableEventType.ORGANIZATION_MEMBER_ROLE_CHANGED,
    CoreAuditableEventType.CROSS_TENANT_ADMINISTRATIVE_ACCESS,
    // Further required event members omitted.
  ],
  // Evidence rationale and other clause fields omitted.
}

The named events define what to look for; their presence in this array does not establish that they occurred. Verification needs the actual records, relevant scope and period, and a way to account for the expected population. A filtered customer export may answer a customer’s investigation without covering an application-wide assurance requirement.

Carry the result into readable documents

The intended workflow starts with measurement from configuration, code, runtime records and repository controls. Evaluation interprets those observations against the obligation. Document facts retain their source and authority; clauses explain those facts for the intended reader. Human acceptance applies to the resulting artifact, with accountable decisions declared where they belong.

QuestionEstablish the answer fromCarry into the document
What information does the application hold?Resource and field declarationsThe categories and purposes relevant to its audience
How is a request handled?Declared operation, authority and data-treatment contractsA clear description of the request process and retained information
Did the mechanism operate?Events, receipts and exercised checksAn observation with a target, scope, time and outcome
How are changes reviewed?Repository history and configured review/release controlsThe actual responsibility and acceptance process
Who operates the service and makes its commitments?Typed operator declarations and attributable decisionsThe responsible party and applicable operating context

Preparation keeps unsupported statements visible for resolution. Fact references and coverage checks help review the content; they do not independently verify every sentence. Publication uses the reviewed content for the declared language and jurisdiction combination.

Revisit findings when the evidence changes

The intended independently hosted assurance service collects evidence for one application using scoped machine access. It evaluates that evidence against a named obligation and retains an explainable finding with a stable identity. Later observations can update or resolve the same finding, preserving what changed and why.

The operator receives committed evidence and findings, with notifications summarizing changes. Measurements remain separate from judged severity: evidence that is inaccessible and a control that did not operate require different responses. Calibration tests both missed failures and false alarms. The historical security-event paper run illustrates this distinction by comparing the audit store with an organization-scoped API; its results belong to its recorded window and revision, not today’s deployment.

Keep the proof level attached to the claim

The model distinguishes selected, configured, operating, evidenced and assessed stages. A required stage expresses a target; reaching it requires the corresponding observation or evaluation. The destination is a repeatable path from product behavior to defensible statements, with independent assessment retaining its own authority.

Use the capabilities below to follow each part of that path: its owning declaration, source example, consumer and outcome. Wildo supplies the shared mechanisms and evidence contracts; application behavior and operating practice establish what can be claimed for a particular service.

Wildo is assurance-ready by construction: the control, its marker and its evidence are one grep apart

Wildo treats auditability, access control, privacy and evidence as properties of the engine rather than as a programme bolted on before an audit. Security-relevant actions emit audit events, authorisation fails closed, access decisions are recorded, and every place in the source that implements a control is marked with a stable objective identifier. What a compliance programme has to say about an application is then composed from what the framework demonstrably does, not from what someone remembers it doing.

The problem it answers

Assurance work in most software is reconstructive. The controls exist somewhere in the code, the policy document was written by someone else, and the evidence is gathered afterwards by people who have to rediscover the link between the two. Every application repeats the reconstruction, the link drifts each time the code moves, and the document ends up describing a version of the system that no longer exists.

The failure has a second, quieter form: a claim written against an external framework’s control identifiers instead of against the code. A row that says “this identifier is satisfied” is a compliance judgement, and forty or fifty of them authored in one pass by whoever happened to be building the artifact is precisely the kind of table nobody can defend later. Wildo’s recorded position is that this judgement belongs to a reviewer, that it lives at a reporting boundary, and that it never selects what the software does.

What it rules in, and what it rules out

Ruled in

  • A marker beside the code. A control is annotated where it is implemented, with a greppable assurance-control: comment naming one or more stable Wildo objective identifiers of the form WILDO.<AREA>.<OBJECTIVE>. An identified but unimplemented control carries the same marker under a TODO(assurance-control) prefix, so a gap is visible in the same search as a control.
  • Fail-closed authorisation and recorded denials. An unresolvable scope yields no rows rather than all rows; a denial is classified and audited; an audit-pipeline failure is itself made detectable.
  • A closed objective catalogue. Forty-nine WILDO.* objectives, each with a title and an intent, each bound in an authority basis to what it demands and which engine capability attests it. A marker naming an objective outside the catalogue fails a mechanical check.
  • Privacy as engine operations. Subject export and erasure are system-access operations that walk every resource, and a resource that has not declared its export posture refuses rather than defaulting.

Ruled out

  • External framework names in production code. External framework and control identifiers live only in a rights-classified mapping boundary used for reports. A checker scans nine trees of the repository, including the templates copied into every generated application and this public marketing tree, and refuses the vocabulary anywhere else.
  • A crosswalk table. The external mapping joins objectives to reviewed source requirements the authority basis already carries, each stamped with the reviewer that approved it. It decides nothing, reproduces no normative text, and asks the rights record of each source whether even storing metadata about it is permitted.
  • Claiming that a control has worked. A binding in the authority basis says what an objective demands and what attests it. It may not claim that the control has actually operated in a given deployment; that is evidence, gathered per occurrence, and a separate report admits which objectives currently have no evidence home.

What it means for someone building with Wildo

  • Ask the source what it does about a control and get an answer in one search: the marker names the objective, the catalogue names the intent, the basis names what attests it.
  • The evidence a programme needs is composed from declarations the application makes about itself (operator identity, jurisdictions, processors, personal-data categories) and from what the engine implements, so a document is regenerated when the application changes rather than rewritten from memory.
  • Your audit events reach your customers’ security tooling per organisation, in a format their products expect, with retry when their endpoint is down. Each customer sees only their own.
  • Subject export and erasure are decisions you make per resource, and the framework refuses to let you skip the decision. Retention that must survive erasure is a declared policy, not a manual procedure.
  • Wildo does not claim to comply with any external framework on your behalf. It gives a reviewer a rights-safe mapping from Wildo’s own objectives to reviewed source requirements, and keeps the external identifiers out of the code that runs.
For engineers

Where it lives in the framework

The chain is short enough to draw:

assurance-control: markers in engine and platform source
        |   checked against the catalogue by check-assurance-control-boundary
        v
INITIAL_ASSURANCE_OBJECTIVE_CATALOG          49 objectives: identifier, title, intent
        |
        v
the authority basis                          what each objective demands, what attests it
        |
        v
an application's compliance programme        which objectives this product commits to
        |
        v
governed documents                           clauses citing facts the application declares
  • Markers. Several hundred assurance-control: lines sit in engine/ and platform/ outside test trees; the count is re-derived by grep rather than quoted. A representative site is the delegated-authorisation consent flow, where the user’s refusal and the user’s approval of third-party access are each marked, and the token issuance that follows is marked again.
  • The boundary checker. check-assurance-control-boundary.mjs reads every marker, verifies each objective identifier against the catalogue, and refuses external framework vocabulary outside the authorised mapping fixture. It runs over engine, platform, examples, scripts, templates, marketing, .github, .claude and the root instructions file.
  • The audit trail. AuditLogsBackendService writes an authoritative audit-log row, dispatches the event to the owning organisation’s configured security monitoring system, and fans out to provider sinks through a composite sink. An event is real only when it has an enum member, a severity classification and a producer; the curated map records that two events once had the first two and no producer, which is why the third is checked by grep before an event is believed.
  • Per-organisation export. A SIEM_EXPORT_CONFIG resource per scope carries destination, authentication or signing, filtering and batching. Dispatch routes on the authenticated organisation and fails closed when there is none, so a caller-supplied organisation cannot redirect another tenant’s events. Structured JSON, CEF, LEEF and OCSF are produced from one projection contract; failed deliveries land in a dead-letter queue with requeue. The end-to-end lanes test the grammar with an independent receiver double; no commercial security product has yet parsed the export, and the corpus says so.
  • Subject export and erasure. exportSubjectAsSystem walks every resource whose configuration declares systemAccessPolicy.exportSubject, with no HTTP route on purpose, because the obligation binds the application as controller. Erasure runs with a retentionPolicy of RETAIN_ONLY or RETAIN_AND_IMPERSONALIZE, so a record the law requires to survive is kept, hidden, frozen and scrubbed field by field; a dogfood scenario drives the whole journey from self-service request to truthful receipt.
  • The mapping boundary. assurance-external-framework-mapping.ts composes the bridge from the authority basis and the source registry, reports per-source rights permissions, and records an objective with no reviewed source as unmapped with the reason rather than inventing one.

Help people sign in. Keep access under control.

Offer the sign-in methods your users need, from passwords and passkeys to their company’s identity provider. Wildo connects them to account policy and the session that follows. Native MFA and identity-provider assurance have separate configuration paths.

The same system handles recovery, sensitive-action confirmation and withdrawal of existing access. You choose the requirements for each kind of account and customer; Wildo supplies the shared flows that apply them.

Alternative sign-in methods pass through policy into a session, with fresh proof for a sensitive action.

One account, a consistent access lifecycle

Choose the right proof

Enable familiar sign-in methods and add stronger proof for privileged accounts. Enrollment and challenges follow the accepted policy.

Honor customer requirements

Application-authored organization-type policies and enterprise identity connections shape access without a separate authentication implementation for every customer.

Control what happens next

Rotate sessions, recover credentials and withdraw access through connected operations. Sensitive actions can require fresh proof even after sign-in.

Example: Protect an administrator without slowing everyday work

An administrator signs in with the required factors, then works normally. Deleting an account asks for fresh proof. If a password is forgotten, its owner requests a recovery link. Completing the password change withdraws earlier tokens.

For engineers

Start with the account type and frontend admission

Application-authored authentication belongs in saas-config.backend.ts. A user type selects authMethodsEnabled, registration policy, password requirements, MFA and session behavior. The frontend’s usersManagement entry decides which types may authenticate or register there. These are separate gates: a configured method is not useful on a frontend that does not admit its user type.

DecisionOwning configurationResult
How an existing account signs inauthMethodsEnabledEligible first and second factors
How a new account is createdregistration plus frontend admissionSignup method and verification path
What a customer requiresApplication-authored organization-type overrides and scoped SSO configurationEffective method and proof requirements
Which actions need fresh proofResource operation and step-up policyReauthentication before the action
How sessions continueUser-type concurrency cap and deployment token settingsSession limits, token expiry and rotation

Follow the authentication state, not just the first response

In native sign-in, a successful first factor may lead to MFA, enrollment or another required step. The standard frontend follows the orchestrator’s result. Provider callbacks use their own completion path and provider-specific assurance settings. Custom clients must follow the result of the chosen path; local MFA policy does not automatically add a challenge to every external callback.

Passkeys need relying-party and origin configuration. Social sign-in needs a provider connection. Enterprise SSO additionally needs the appropriate scope connection and verified routing authority. The detailed sections below connect each method to those prerequisites.

Keep credential and account changes on their owning operations

Password changes, forced resets and sign-out-everywhere use token invalidation. Single-session logout has a narrower scope. Cross-tab coordination clears browser state, while backend guards remain responsible for refusing withdrawn credentials.

Account suspension, voluntary deactivation and deletion have different transitions. Restoring a voluntarily deactivated account is an administrative action. Avoid implementing these as direct patches to status, roles or credential storage: doing so would omit the lifecycle behavior attached to the named operations.

Continue with account policies, fresh proof for sensitive actions and session rotation for their connected contracts.

Choose how people sign in

Set sign-in rules for each kind of account Feature

Members and administrators do different work and carry different responsibility. Give each kind of account its own password requirements, failed-attempt policy and enabled sign-in methods.

Wildo applies those choices through the shared sign-in flow. Your application defines the policy; it does not need to implement a separate login system for every audience.

Example: Stronger requirements for administrators

Members can use the normal password policy, while administrators need a longer password and face a tighter failed-attempt limit. Both use the same application sign-in experience.

Member and administrator account types each have their own password rules.
For engineers
Declare the policy where the user type is defined

saas-config.backend.ts owns application-authored identity behavior. In Wonder Todos, these are selected settings from the admin.auth object:

passwordPolicy: {
  minLength: 12,
  maxLength: 128,
  requireUppercase: true,
  requireLowercase: true,
  requireNumbers: true,
  requireSpecialChars: true,
  expiryDays: 90,
},
sessionDurationMinutes: 30,
maxConcurrentSessions: 3,
lockoutPolicy: {
  maxLoginAttempts: 3,
  lockoutDurationMinutes: 60,
  progressiveLockout: true,
  maxProgressiveLockoutHours: 24,
},

Password length, composition and expiry govern the credential. lockoutPolicy governs failed attempts. sessionDurationMinutes limits the temporary authentication episode in which sign-in steps are completed; it does not set the issued access token’s expiry. Registration and invitation episodes are created with a 24-hour expiry; idle timeout and subsequent session-store updates also affect how long they remain usable. maxConcurrentSessions limits authenticated sessions. JWT signing keys and access/refresh token lifetimes belong to deployment runtime configuration.

Make the user type available on the frontend

Enabling AuthMethod.PASSWORD is necessary, but the frontend must also admit the type. Wonder Todos declares:

frontendServices: {
  'wonder-todos-app': {
    usersManagement: {
      member: { register: true, auth: true, isDefault: true },
      admin: { register: false, auth: true },
    },
  },
},

An administrator can authenticate on this frontend but cannot register there. registration.allowedMethods separately controls account creation; the enabled sign-in set is not a signup policy.

Let the authentication flow finish

The standard interface identifies the account’s applicable methods and continues authentication. Password verification alone is not necessarily the final result: required email verification, MFA and account status still matter before a usable session is issued. Custom clients should follow the returned authentication state rather than assume that a successful first factor means access has been granted.

Sign in with a device you already trust Feature

Let people use a passkey instead of remembering a password. Wildo handles the challenge, credential enrollment and sign-in verification, and keeps passkeys attached to the person’s existing account.

Your application enables the method and defines its relying-party settings. People can manage their enrolled credentials through the supplied account controls.

Example: Use a passkey on the next visit

A member enrolls a passkey while authenticated. On a later visit, their authenticator proves possession for this application and the normal account policy decides the remaining sign-in steps.

A person uses a fingerprint on their phone to access their account with a passkey.
For engineers
Configure the application identity first

Enable AuthMethod.PASSKEY for the relevant user type and configure the passkey relying party and operational settings. Expected origins come from configured runtime public URLs. These values must match the deployed frontend; enabling the flag alone does not make a credential valid for an arbitrary hostname.

Enrollment proves the application’s challenge

The standard interface requests registration options, invokes the browser authenticator and submits its response. The backend consumes the stored registration challenge and verifies it against the relying party and expected origin. This is the verification call in webauthn.backend.service.ts:

const verification = await verifyRegistrationResponse({
  response,
  expectedChallenge,
  expectedOrigin: this.expectedOrigins,
  expectedRPID: this.rpId,
  requireUserVerification: this.passkeyOps.userVerification === 'required',
});

Successful verification stores the credential’s public key, identifier and authenticator metadata. The secret stays with the authenticator. Public signup still requires email proof before passkey enrollment; an authenticator is not proof of an inbox address.

Authentication has its own ceremony

A later sign-in creates a fresh authentication challenge. The backend consumes it once, verifies the assertion and updates the credential’s counter and last-used information. Synced passkeys with a zero counter are supported; a verified assertion whose nonzero counter regresses is refused and recorded as suspicious.

Sensitive-action reauthentication uses a separate passkey challenge and requires user verification. That distinction prevents a login response from being reused for a high-impact action. Enrollment, naming and removal are separate account operations; removing a credential remains subject to account method-management policy.

Confirm access with a phone code Feature

Offer a short-lived code sent to a verified phone when that method fits the account policy. Wildo connects phone verification, delivery limits and code checking to the normal authentication flow.

You choose where SMS is enabled and configure a delivery provider. Verifying a phone number and signing in with it remain separate actions.

Example: Use an enrolled phone as a second step

A member first proves their primary credential, then receives a code on their verified phone. The code completes the enabled second-factor step.

A verified phone displays a one-time code beside a clock and confirmation mark.
For engineers
Establish the phone before using it as proof

Phone enrollment uses phoneVerifySend(phoneNumber) and phoneVerifyConfirm(phoneNumber, code). Confirmation persists the verified phone on the credential record. This enrollment action does not itself sign the person in.

Send and submit the authentication challenge

The standard SmsOtpAuthMethod.tsx receives an authentication sessionId and uses it for both steps. The send handler calls getManualCallsHttpClient().smsOTPSend(sessionId); it does not let the person substitute an arbitrary destination for their verified phone. The submit handler then continues that same authentication session:

const handleVerify = useCallback(async (data: SmsOtpChallengeFormDto) => {
  clearCodeFormError();
  try {
    const response = await handleMfaVerify(sessionId, AuthMethod.SMS_OTP, data.code.trim());
    if (response.success) {
      onSuccess(response);
    } else {
      setCodeFormError(response.error || t(MfaChallengeLabel.ERROR_INVALID_CODE));
    }
  } catch (e: any) {
    setCodeFormError(e?.message || t(MfaChallengeLabel.ERROR_INVALID_CODE));
  }
}, [sessionId, handleMfaVerify, onSuccess, t, clearCodeFormError, setCodeFormError]);

This is the existing frontend challenge handler, not a complete standalone component. handleMfaVerify comes from the authentication flow, while onSuccess hands its response back to the parent. Failed verification stays in the challenge form; a successful code follows the server’s authentication result.

Enable the method and wire the provider

Start with the SMS delivery setup: enable EngineCapability.SMS, select the backend Twilio provider, synchronize its artifacts, supply TWILIO_CREDENTIALS and configure the sender number. Keep the secret in the backend deployment environment. The provider guide contains the complete configuration fragments; choosing an authentication method does not perform those steps.

In backend-api/src/saas-config.backend.ts, merge this policy into the intended userTypes.<type>.auth. Import AuthMethod from @wildo-ai/saas-models; existingMemberAuth is that type’s existing complete policy:

const memberAuth = {
  ...existingMemberAuth,
  authMethodsEnabled: {
    ...existingMemberAuth.authMethodsEnabled,
    [AuthMethod.SMS_OTP]: true,
  },
};

SMS can be a first or second factor. This declaration enables the method; it does not require MFA or weaken an existing MFA requirement. For a password-then-SMS flow, password must also be enabled and the first credential must succeed before the SMS challenge completes the session. If SMS should qualify as a second factor, include AuthMethod.SMS_OTP in mfaPolicy.acceptableMFAMethods and check mfaPolicy.requireStrongMFA within the same user-type authentication policy. Keep the MFA policy consistent with the factors the application intends to accept.

Set the challenge window and sending budgets

Wonder Todos supplies the following backend operational settings under auth.otpOperational.smsOtp. These are explicit example values, not a claim that enabling SMS installs a provider or enrolls a phone. Merge this entry alongside the existing email-OTP settings:

smsOtp: {
  otpLength: 6,
  expiryMinutes: 5,
  rateLimitBurst: 1,
  rateLimitHourly: 5,
},

The burst and hourly limits apply to the resolved phone destination; the SMS-send controller also enforces its own source-IP limit. Increasing a phone budget does not remove the source-IP gate. The phone-enrollment challenge is a separate flow with its own checks; these settings describe authentication SMS codes.

StepWhat must already be trueObservable result
Enroll the phoneThe account can access the phone-verification flow and delivery is configuredConfirmation records the verified phone; it does not sign in
Request a sign-in codeThe authentication session is in an eligible state and effective policy allows SMSThe provider accepts the submission, or the request fails
Submit the codeUse the same session and the received code within its validity windowAuthentication advances according to the server response

An accepted send is not evidence that the handset received the message. Complete the challenge on a controlled account to verify the real delivery path. A missing or expired authentication session, exhausted budget or provider refusal must not be reported as successful delivery.

The SMS backend service uses the provider’s request builder and response parser, validates the standard result and refuses provider-reported send failures.

Authentication issuance resolves the verified target, checks effective policy and applies delivery budgets. An older outstanding code is revoked when a replacement is created. Verification uses the authentication session and advances the normal flow after a successful comparison.

Respect the customer’s identity authority

An actively directory-managed account has local SMS authentication disabled by the effective policy, just as local passwordless methods are disabled. The phone belongs to the person and is not a revocable IdP secret, so the method’s policy is the barrier. Choose stronger factors when the account’s requirements call for independent device proof; SMS availability is not a claim that every security policy should accept it.

Let people use an existing sign-in identity Feature

Offer an external provider as a way into the application. Wildo handles the redirect, callback and identity linking, while the application keeps its own account and membership model.

You choose the provider and account policy. A verified provider identity must still meet the application’s conditions for linking or creating an account.

Example: Join through a familiar provider

A person signs in through an enabled provider, proves their verified address and reaches their application account rather than receiving a duplicate account on every visit.

An identity provider connects to a verified local account, with customer membership shown separately.
For engineers
Configure more than the button

Enable AuthMethod.EXTERNAL_OAUTH2 for the user type, supply the provider connection and credentials, and permit social registration separately if new accounts may be created. The provider’s declared scope and supported profile mapping determine which identity is returned.

The connected provider setup joins the backend login declaration, application client ID, server secret, per-user-type method and exact callback address. Use those prerequisites together; a provider button is only the visible entry to that flow.

Choose sign-in and registration separately

Example: allow an existing member to use a social provider, while keeping new-account registration closed. In backend-api/src/saas-config.backend.ts, merge this into the member’s existing auth policy. existingMemberAuth represents that full policy; retain its password, session, lockout and MFA settings. Import AuthMethod and RegistrationMode from @wildo-ai/saas-models.

const memberAuth = {
  ...existingMemberAuth,
  authMethodsEnabled: {
    ...existingMemberAuth.authMethodsEnabled,
    [AuthMethod.EXTERNAL_OAUTH2]: true,
  },
  registration: {
    ...existingMemberAuth.registration,
    mode: RegistrationMode.DISABLED,
    allowedMethods: [],
  },
  autoLinkByEmail: false,
};

This example also disables automatic email-based linking. Existing linked identities can sign in; a matching email alone cannot attach a new identity to the account. This posture is for accounts whose external identity is already linked; it does not establish that first link. If intentional verified-email linking is required, review and enable autoLinkByEmail across the target account’s held user types instead. Registration policy governs new accounts; it does not remove existing identity links.

The initiating frontend must also allow the same member type in frontendServices.<service>.usersManagement with auth: true. To offer public social registration instead, use RegistrationMode.OPEN, include AuthMethod.EXTERNAL_OAUTH2 in registration.allowedMethods, and enable register for that frontend/user-type pair. These are additional admissions, not consequences of displaying a provider button.

Returning identityWhat Wildo checks
Already linked subjectThe linked local account and its applicable sign-in policy
New subject with an existing verified emailEmail-linking policy across all held target account types; any explicit opt-out refuses linking
New subject and new emailSocial registration policy, allowed method and frontend registration admission
Let the standard flow carry the proof

The standard authentication flow stores one-time state and, where supported, a PKCE verifier before redirecting. OIDC requests also carry a nonce. On return, Wildo consumes the state, exchanges the authorization code and binds the verified identity to the request. A returned ID token must pass validation even when the profile also comes from a user-info endpoint.

A custom frontend should start and finish this flow through the authentication client. A browser-supplied profile, matching email or successful redirect is not authentication evidence.

The service first looks for the external subject’s existing link. Verified email is required for email-based linking and new social provisioning. Unverified provider email is not treated as account ownership. Existing-account linking and new-account registration are different decisions; configure both intentionally.

Profile and pending-invitation reconciliation run through the account/provisioning services. Enterprise SSO remains a separate tenant-owned connection model. A social provider button does not by itself satisfy a customer’s requirement to control its organization’s identity provider.

Add proof where it matters

Require another proof when it matters Feature

Add a second proof to sign-in for the accounts that need it. Wildo connects the policy, enrollment, challenge and recovery-code flow, so people are offered methods the application will actually accept.

You choose which factors qualify, whether MFA is required and how enrollment is introduced. A passkey can satisfy the policy without an extra challenge when you allow that.

Example: Protect privileged accounts

An administrator signs in with a password and confirms an authenticator code. Members can have a different policy, while recovery codes provide a way back when an enrolled device is unavailable.

Sign-in is followed by additional proof on a phone before entering the application.
For engineers
Choose acceptable factors, not just an MFA switch

The factor must be enabled as well as accepted by the MFA policy. Put both declarations under userTypes.<type>.auth in backend-api/src/saas-config.backend.ts; changing only acceptableMFAMethods does not enable an authenticator.

Example: require an authenticator app after password sign-in. existingAdminAuth is the application’s existing complete policy. Import AuthMethod from @wildo-ai/saas-models and merge this result back into that user type.

const adminAuth = {
  ...existingAdminAuth,
  authMethodsEnabled: {
    ...existingAdminAuth.authMethodsEnabled,
    [AuthMethod.PASSWORD]: true,
    [AuthMethod.TOTP]: true,
  },
  mfaPolicy: {
    ...existingAdminAuth.mfaPolicy,
    requireMFA: true,
    requireStrongMFA: true,
    acceptableMFAMethods: [AuthMethod.TOTP],
    enrollmentGracePeriodDays: 0,
    passkeyExemptFromMFA: false,
  },
};

This example preserves other enabled first factors. Review those deliberately if the application must require password specifically. TOTP is a second factor, not an alternative first-factor login. The account must enroll it through the standard setup flow; enabling the method does not create a secret for anyone.

SettingEffect
requireMFARequires additional proof for the applicable sign-in flow
requireStrongMFAExcludes factors the engine does not classify as strong
acceptableMFAMethodsRestricts which enabled second factors qualify
enrollmentGracePeriodDaysControls the enrollment transition; zero provides no grace interval
passkeyExemptFromMFAControls whether a passkey avoids another challenge
Decide what an external login must demonstrate

The same user type can receive social logins, whose evidence is assessed separately from native challenges. Set externalAssuranceEnforcement: ExternalAssuranceEnforcement.STRICT inside its mfaPolicy to refuse missing or insufficient required assurance; import the enum from @wildo-ai/saas-models. Omission means warn and audit, not strict refusal.

The TOTP-specific policy above cannot be satisfied merely by a provider saying “MFA happened.” Generic upstream MFA does not establish that a particular local factor was used. A configured enterprise OIDC connection instead delegates MFA enforcement to the customer’s IdP. It still must pass identity and token validation. Organization overrides can tighten the applicable policy; they cannot weaken strict enforcement.

Enrollment and verification are different steps

The TOTP setup service creates a secret, a QR-code URI and recovery codes. The person must prove a generated code before setup is marked verified. Recovery codes are shown for the person to retain; stored recovery values are hashed. TOTP acceptance atomically claims its absolute time step in Redis. Recovery-code consumption uses a conditional credential update, so a concurrent second consumption cannot also succeed.

The engine uses resolveAcceptedSecondFactorMethods for both offered and accepted methods. This prevents a flow from asking someone to enroll a factor that the next challenge refuses. In the native sign-in flow, the orchestrator carries the first-factor state into the second-factor step and issues the session after the required proof completes.

Registration completion is also distinct from a returning user’s login challenge. It can complete with an already-enrolled factor or under configured enrollment grace; otherwise it directs the person to enrollment.

Plan the recovery route

An enrolled TOTP code and an unused recovery code are checked through the same verification service. A used recovery code is removed from the stored set. The standard flow supplies enrollment and challenge interfaces; the application still decides which account types require protection and which factors are appropriate for their users.

Ask for fresh proof before a sensitive action Guarantee

A person can stay signed in for everyday work and still prove their identity again before a high-impact action. Wildo connects the operation’s requirement to a fresh authentication challenge and resumes the action after verification.

You select the operations and accepted proof methods. The extra check complements their access rules; it does not grant a role the person lacks.

Example: Confirm who is deleting an account

An administrator can browse account settings normally. Deleting an account requires fresh proof before the operation proceeds.

A person working in the application confirms their identity before completing a sensitive action.
For engineers
Put the requirement on the operation

The engine’s user-deletion API variant declares both who may call it and the additional proof it requires. Selected fields from users.shared.resources-config.schemas.ts:

variantType: ResourceOperationVariantType.API_CALL,
isDefault: true,
roles: [CORE_APP_ROLES.APP_ADMIN_SUPER_ADMIN],
admitsCrossSubjectUserAdministration: true,
riskLevel: ResourceOperationRiskLevel.CRITICAL,
requiresStepUpAuthentication: true,

riskLevel controls how danger is presented; it is not the reauthentication gate. requiresStepUpAuthentication is the explicit requirement. The role gate remains in force.

Exchange a fresh factor for an operation proof

The reauthentication service accepts password, an enrolled TOTP or recovery code, or a passkey assertion. Passkey step-up uses its own challenge and requires user verification, so a normal login assertion cannot be replayed as step-up.

After verifying the factor, AuthMethodManagementBackendService produces this proof:

const token = await this.consumableTokenService.createToken({
  tokenType: CoreConsumableTokenTypes.REAUTH,
  consumptionMode: ConsumableToken_ConsumptionMode.SINGLE_USE,
  expiresIn: { value: 5, unit: DurationUnit.MINUTES },
  userId: userId,
  resourceIdentifier: CoreResourceType.USERS,
  relatedId: userId,
});
return { reAuthToken: token.token };

The standard frontend’s StepUpAuthContext coordinates the challenge and pending action. The backend operation gate consumes the proof for the authenticated person; a custom interface needs the same continuation rather than treating the challenge as an independent login screen.

Keep policy and enrolled methods compatible

When the effective stepUpAuth policy is enabled, its methods constrains which factors may mint the proof. The resolver includes the actual subject’s organization memberships, so organization restrictions participate rather than reading only the raw user-type policy.

An operation’s explicit requiresStepUpAuthentication can require proof even when the policy-level switch is disabled; in that case the enabled-policy method restriction does not apply. The resource-operation gate covers direct user requests, with exemptions for worker execution, nested service calls and its declared read-operation set.

A person must have a factor the minting path accepts enrolled locally; an SSO identity alone is not a password, TOTP or passkey proof. A proof already minted remains usable until its short expiry even if the accepted-method policy changes in the meantime.

Adapt sign-in to each customer’s requirements Feature

Define authentication requirements for the organization types your application serves. Wildo combines those application-authored requirements with the person’s account policy when they sign in.

For people in several organizations, the combined requirements matter. Your application defines the override on each organization type and controls whether its method set may narrow or expand the base policy.

Example: Meet a customer’s stronger password requirement

A member belongs to an organization requiring a longer password and MFA. Their effective policy reflects those requirements instead of relying only on the application’s basic member settings.

Application and customer policies combine into the effective sign-in rules.
For engineers
Start from the user-type policy

These overrides are authored in application configuration, not a per-customer settings record. Separately managed SSO connection settings govern provider routing and enforcement.

organizationTypes[type].authOverrides can describe authentication methods, MFA, password requirements and step-up policy. Registration, lockout and session length are not organization-overridable fields in this contract.

The pure resolveAuthConfig function resolves each organization’s override against the base and then combines the results. For password bounds, the actual merge uses:

function mergePasswordMostRestrictive(a: PasswordPolicyConfig, b: PasswordPolicyConfig): PasswordPolicyConfig {
  return normalizePasswordPolicyBounds({
    minLength: Math.max(a.minLength, b.minLength),
    maxLength: Math.min(a.maxLength, b.maxLength),
    requireUppercase: a.requireUppercase || b.requireUppercase,
    requireLowercase: a.requireLowercase || b.requireLowercase,
    requireNumbers: a.requireNumbers || b.requireNumbers,
    requireSpecialChars: a.requireSpecialChars || b.requireSpecialChars,
    expiryDays: mergeExpiryDays(a.expiryDays, b.expiryDays),
  });
}

Longer minimum length and shorter maximum/expiry represent different directions numerically but the same stricter intent. MFA requirement uses true-wins; a shorter enrollment grace period wins.

Decide how method changes are allowed
Method policyOrganization behavior
RESTRICT_ONLYNarrow the base enabled methods
EXPAND_WITHIN_SETAdd only methods from the application’s allowed expansion set
UNRESTRICTEDUse the organization’s configured method set

Across multiple organizations the resolved method sets are intersected. Do not assume that a method available in one membership remains available when another organization’s requirements are included.

Keep the effective policy at the point of use

The backend gathers relevant memberships and resolves policy for authentication and credential changes. Active directory management additionally disables local first-factor methods that would bypass the directory. Step-up methods can be narrowed and freshness shortened where a base step-up policy exists. Custom flows should use the effective policy rather than reading the user-type defaults alone.

Connect a customer’s identity provider

Use your customer’s identity provider for sign-in Feature

Connect an organization’s identity provider through OpenID Connect. Members use the customer’s sign-in process, while Wildo connects the verified identity to the application’s account and membership rules.

Each connection belongs to its configured scope. Domain verification and connection policy determine when it is offered and how people are provisioned.

Example: Sign in through the company directory

A member enters their company address, follows the organization’s configured identity provider and returns to the application’s workspace with the permitted membership.

Acme's identity provider connects through OIDC to the application and an Acme membership.
For engineers
Establish the connection and routing authority

The user type must enable AuthMethod.ORG_OPENID_CONNECT. The organization also needs an enabled SSO connection, client configuration and verified domain routing. Claiming a domain is not verification; DNS verification grants the routing authority. The application-scope connection has its own authorize path.

Connect the method, tenant and identity provider

Example: permit OIDC for a user type whose other authentication policy already exists. Merge into userTypes.<type>.auth in backend-api/src/saas-config.backend.ts. Import AuthMethod from @wildo-ai/saas-models.

const customerAuth = {
  ...existingCustomerAuth,
  authMethodsEnabled: {
    ...existingCustomerAuth.authMethodsEnabled,
    [AuthMethod.ORG_OPENID_CONNECT]: true,
  },
};

This permits the method; it does not create an IdP connection, verify a domain or grant membership. The initiating frontend must admit the same user type. Organization method policy can further restrict or expand available methods within the application’s declared rules.

Setup ownerWhat must agree
Application auth policyOIDC is permitted for the intended user type and frontend
Scoped SSO connectionAn enabled connection belongs to the application or customer organization being authenticated
Identity-provider applicationClient credentials and the callback registered for this deployment match
Domain routingOrganization discovery uses verified domain ownership, not an unverified domain claim
Provisioning policyThe verified subject maps to the intended local account and permitted membership

Use the standard scoped connection configuration service for manual metadata, provider templates or discovery. Those modes supply issuer, endpoints and keys; they are not three different trust policies. Keep client secrets in the backend configuration/secret path and register the callback supplied for the selected scope and deployment.

Verify the returned identity

Wildo validates the ID-token signature, expected issuer, client audience, expiry and issuance time before trusting its claims. It binds the nonce to the request and the identity to the returned profile. Missing verification metadata refuses ID-token processing; an unverified decode is not a fallback.

If the connection declares acrValues, the verified token must contain an accepted acr. Requesting a value does not prove it was returned, and a generic amr list does not substitute for the requested authentication context. Delegated enterprise MFA does not bypass this explicit demand.

Connect authentication to provisioning

The enterprise provisioning service resolves the external subject, local account, profile and membership. Just-in-time creation follows configured provisioning rules; directory provisioning is a separate lifecycle channel for joiners and leavers. Enterprise OIDC accepts delegated MFA assurance from the configured customer identity provider; it does not claim Wildo observed individual factors. The customer owns the IdP’s authentication policy, while Wildo retains token validation, identity binding and local access checks. Callback completion uses its own admission path and does not enter the native second-factor continuation.

Keep local access policy deliberate

Enforced SSO, migration grace and named emergency accounts are policy choices on the scope’s configuration. Active directory management disables local first-factor paths that would bypass the customer’s directory. These decisions belong beside connection setup, not in a custom callback that silently falls back to a local password.

This capability makes Wildo a client of the customer’s identity provider. Wildo’s own OAuth authorization-server and delegated-agent access surfaces answer a different question.

Connect enterprise sign-in through SAML Feature

Support a customer’s SAML identity provider without rebuilding the sign-in and account-linking flow. Wildo validates the returned assertion and connects it to the intended organization’s account policy.

Connection metadata, certificates and tenant binding remain explicit, including when several customers use the same identity provider.

Example: Keep two customers on their own connections

Two organizations use the same directory vendor. A returned assertion must identify the intended customer through the validated connection and signed audience, not just the provider name.

A signed SAML document bridges an identity provider and Acme's application.
For engineers
Prepare both sides of the connection

Enable AuthMethod.ORG_SAML for the relevant user type and configure the scope’s SAML connection. Exchange the application’s service-provider metadata and the identity provider’s settings, including certificates and endpoints. The configured runtime must provide the XML-validation helper used by the SAML implementation.

Create the organization-owned SSO connection with protocol: SsoProtocol.SAML, a displayName, and the provider’s saml settings. The following excerpt from sso-connection.shared.schemas.ts shows the connection fields the settings form and request contract use:

saml: z.object({
  entityId: z.string().min(1).isAuditEvidence(),
  ssoUrl: z.url().isAuditEvidence(),
  sloUrl: z.url().optional().isAuditEvidence(),
  x509CertificatePem: z.string().min(1).optional(),
  x509Certificates: z.array(z.string()).min(1).isBackendOnly(),
  attributeMapping: z.object({
    email: z.string().default('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'),
    firstName: z.string().default('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'),
    lastName: z.string().default('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'),
    roles: z.string().optional(),
    department: z.string().optional(),
    employeeId: z.string().optional(),
    groups: z.string().optional(),
  }),
  signRequests: z.boolean().default(true).isAuditEvidence(),
  wantAssertionsSigned: z.boolean().default(true).isAuditEvidence(),

This is the relevant portion of the existing schema, not a complete connection payload. Enter the IdP’s entity identifier and SSO endpoint, supply its signing certificate through x509CertificatePem, and align attribute names with the assertions it sends. The backend derives the internal certificate array; clients do not write that backend-only field. Connection-level allowedDomains, defaultRole and autoCreateUsers govern who can enter and how new local accounts are provisioned.

On the provider side, use Wildo’s service-provider metadata for the intended organization. The metadata endpoint supplies that configuration; the assertion-consumer endpoint receives the provider’s response. Enable the connection and start sign-in through it: a valid assertion then enters the local account and membership resolution flow.

Bind the assertion before selecting the customer

SP-initiated sign-in carries request correlation. For IdP-initiated sign-in, the engine validates candidate connections against their configured certificates before selecting a scope. If several tenants share an identity provider and certificate, the signed audience must identify one intended tenant; unresolved ambiguity is refused.

Assertion conditions and replay protection are handled by the validator and replay guard. An unsigned issuer label is not tenant authority. Multiple configured certificates support certificate rollover without disabling validation.

Complete the account and logout lifecycle

After validation, the enterprise provisioning path resolves the local account and membership under the connection’s policy. SAML completion checks the local account and issues tokens on its callback path; it does not automatically enter the native MFA challenge continuation. Set the customer’s provider assurance requirements deliberately rather than assuming local mfaPolicy is translated into a SAML demand. Separate SAML logout paths manage the federated session behavior. Test the customer’s configured login direction and logout path with its provider; protocol support alone does not establish that a particular customer’s metadata and certificates are correct.

Manage ongoing access

Keep sessions usable while credentials rotate Guarantee

Keep people signed in without treating a long-lived token as permanent authority. Wildo refreshes access through rotating credentials and checks the account’s current state before continuing the session.

A short retry window handles ordinary duplicate refreshes. Reuse outside that window triggers token withdrawal rather than silently extending access.

Example: Retry a refresh without losing the session

A refresh succeeds but its response is lost. Once the successor is cached, a prompt retry with the retired credential can receive that same successor. Replaying it after the grace window is treated differently.

A retired token is replaced by the next token, with retries returning to that successor.
For engineers
Configure session policy at the right level

Wonder Todos’ administrator type declares:

sessionDurationMinutes: 30,
maxConcurrentSessions: 3,

sessionDurationMinutes is the lifetime of the temporary sign-in episode, not the duration of the access JWT it eventually produces. Registration and invitation episodes are created with a 24-hour expiry; idle timeout and subsequent session-store updates also affect how long they remain usable. maxConcurrentSessions controls the number of authenticated sessions. JWT signing material and access/refresh token lifetimes are deployment-authored runtime settings.

The standard client sends the refresh request with the HTTP-only refresh cookie. The endpoint takes an empty body; a JavaScript client should not read, store or submit the refresh token itself. Access-token updates are propagated to API clients and coordinated between same-origin tabs.

The controller reads the cookie after validating the request shape. Selected code from authentication-controller.backend.service.ts:

const ec = await this._getPreAuthExecutionContext();
RefreshTokenBodySchema.parse(req.body || {});
const refreshTokenValue = AuthControllerUtils.extractRefreshTokenFromCookie(req);
if (!refreshTokenValue) {
  throw this.errorBuilder.buildError(ErrorType.VALIDATION, undefined, {
    customMessageReference: ErrorCustomMessageReference.AUTHENTICATION_REFRESH_TOKEN_REQUIRED,
  });
}
Follow the order of the backend checks
CheckWhy it precedes rotation
Token validity and live accountA valid signature does not keep a disabled account active
Authorization versionA session cannot retain an older authority snapshot
Account and session revocationA retry cannot cross a withdrawal
Retired token and grace successorA short duplicate can receive the already-issued successor

AuthTokenIssuerBackendService.refreshAccessToken returns a cached successor when the predecessor is already marked as rotated and its grace entry exists. Concurrent refreshes select one successor in a Redis transaction that also records the retired credential and applies the winner’s session-limit effects. Losing requests receive the stored successor; they do not register extra sessions. If a response is lost after that transaction, a retry can recover the same tokens during the grace window. After the grace window expires, reuse of the retired credential writes the account-wide invalidation fence and reports a distinct reuse event. The raw token is not part of that signal.

Concurrent-session enforcement uses the shared session store and eviction path. A custom authentication integration must preserve these issuance and refresh paths; manually signing a replacement JWT would bypass the session lifecycle that makes the policy meaningful.

End access across a person’s sessions Guarantee

Withdraw a person’s existing tokens across devices when they sign out everywhere or a credential needs to be replaced. Wildo checks the revocation state during token use and refresh, rather than waiting only for normal expiry.

Individual-session logout remains a separate action, so ending one session does not have to end all the others.

Example: Respond to a compromised password

A password reset withdraws the person’s previous tokens. Another device cannot keep refreshing the old session after the reset.

Signing out everywhere from one account revokes its laptop, phone and tablet sessions.
For engineers
Use the account-wide revocation path

The authentication controller exposes logoutAll for the current user. Credential-reset services also use the issuer’s account-wide invalidation method. This is its current implementation:

public async invalidateAllTokensForUser(userId: string): Promise<void> {
  const nowSeconds = Math.floor(Date.now() / 1000);
  const refreshTokenDays = this.appConfigService.config.jwt.refreshTokenExpirationDays;
  await this.redisService.set(
    `${AUTH_REDIS_KEYS.TOKEN_INVALID_BEFORE}${userId}`,
    nowSeconds,
    { ttl: refreshTokenDays * 86400 },
  );
  this.logDebug('All tokens invalidated for user', { userId, invalidBefore: nowSeconds });
}

The fence expresses which earlier tokens are no longer admissible. The JWT guard, execution-context creation and refresh path consult it. A custom credential-changing flow must use the owning service rather than update the password hash and leave existing sessions untouched.

Choose the scope of logout deliberately

invalidateSession(sessionId) is the sibling for one login session. Account-wide invalidation affects the user’s earlier sessions; per-session invalidation withdraws only the selected one. Cross-tab logout separately clears the current browser’s visible state and client credentials.

Treat the revocation second as part of the boundary

The shared evaluateUserTokenInvalidationFence compares whole-second issuance times with the stored revocation second. It deliberately uses an inclusive comparison:

return { isRevoked: issuedAtSeconds <= invalidBefore, invalidBefore };

A token issued before the boundary is refused, and so is one issued in the same second. A login racing the revocation can therefore obtain a token that is immediately rejected: those timestamps cannot distinguish issuance just before the revocation from issuance just after it. Sign in again after that second has passed; do not relax the comparison or keep retrying the withdrawn token.

Token issuance timeThis timestamp fence’s verdict
Before the revocation secondRevoked
In the revocation secondRevoked, including a concurrent fresh login
After the revocation secondNot revoked by this fence; all other checks still apply

The atomic refresh publication path repeats the inclusive comparison inside Redis, so a revocation arriving while a successor is being signed is checked again before publication. Timestamp revocation, authorization-version changes and individual-session withdrawal are distinct checks; passing one does not bypass another. Test the equality case alongside an older token and a genuinely later issuance.

Understand the observed result

The next guarded request or refresh with a withdrawn token is refused. This does not reverse requests that already completed. Refresh checks revocation before returning a cached successor, so its short retry window cannot preserve access past a revocation. Keep the distinction between local interface cleanup, one-session logout and account-wide token withdrawal explicit in custom security controls.

Keep open tabs in step when you sign out Feature

Signing out should not leave another tab looking active. Wildo tells the application’s other same-origin tabs to clear their authentication state, API credentials and live connection.

This keeps the browser experience consistent while the backend handles token revocation.

Example: Leave the account in every open tab

A person signs out in one tab. Their other application tab clears its signed-in state too, rather than continuing to display working account controls.

Signing out in one browser tab sends a signal that signs out the other tabs.
For engineers
The standard authentication provider coordinates tabs

AuthSessionContext uses the wildo-auth-tokens BroadcastChannel. Logout calls the backend, broadcasts LOGOUT, disconnects the live connection and clears local credentials. The receiver performs this selected cleanup from AuthSessionContext.tsx:

if (type === 'LOGOUT') {
  logDebug('Received logout signal from another tab');
  disconnect();
  tokenStorage.clear();
  getManualCallsHttpClient().setConsumableToken(null);
  setResourcesConsumableToken(null);
  storeAuthTokens(null);
  getManualCallsHttpClient().setAuthToken(null);
  setResourcesAuthToken(null);
  setAuth(EMPTY_AUTH_STATE);
}

Clearing both the manual HTTP client and resource client matters: an empty React account display alone would leave requests carrying an old credential. Consumable-token state is cleared as well. The receiving tab does not rebroadcast logout.

Use the shared provider in custom screens

Call the provider’s logout operation rather than removing a storage item yourself. Token refresh is coordinated on the same channel so tabs can adopt the new access token; the HTTP-only refresh cookie remains outside JavaScript.

Know the browser boundary

BroadcastChannel coordinates same-origin tabs where the browser supports it. It does not reach another device or unrelated domain. Backend logout is still the credential authority, and sign-out-everywhere is the separate account-wide mechanism. The sender clears its local state even if the network logout call fails, so a visibly signed-out tab is not by itself proof of server-side revocation.

Give a link a purpose and a lifetime Mechanism

Use a token for a specific interaction: accepting an invitation, resetting a password, carrying a redirect or granting bounded access. Wildo records its purpose, expiry and allowed use, then checks those conditions when it is redeemed.

Choose single use or bounded reuse according to the action. A token does not automatically become a full account session.

Example: Accept an invitation once

An invitation link identifies the intended acceptance. Competing requests cannot both consume a single-use token successfully.

A time-limited ticket for one action passes a check and is marked used.
For engineers
Declare what the token is for

The password-reset service is a concrete consumer of the shared token mechanism. It mints this token before building the email link:

const resetToken = await this.consumableTokenService.createToken({
  tokenType: CoreConsumableTokenTypes.PASSWORD_RESET,
  consumptionMode: ConsumableToken_ConsumptionMode.SINGLE_USE,
  expiresIn: { value: PASSWORD_RESET_TOKEN_TTL_HOURS, unit: DurationUnit.HOURS },
  userId: user._id,
  resourceIdentifier: CoreResourceType.USERS,
  relatedId: user._id,
  metadata: { email: user.email },
});

Purpose, subject, related resource and expiration travel together. The reset handler later checks that the consumed token is a PASSWORD_RESET token before changing the credential. The token value is a secret, not a record identifier to display publicly.

Select the right lifecycle
Consumption modeIntended interaction
SINGLE_USEOne successful redemption, such as accepting an invitation
BOUNDED_REUSEA limited number of uses within an expiry
EPHEMERAL_STATECorrelation across a redirect, consumed on return

Validation checks the current record. Consumption performs a conditional atomic mutation so two callers cannot both claim the last permitted use. Revocation is also guarded and makes an outstanding token unusable.

Reach it through the owning operation

Standard flows already supply their token consumers. Application operations can use declarative token generation, which reaches the same mint service. Define the intended resource/action target and the point of consumption; do not use a generic token as an implicit permission to call unrelated operations. Session-establishing links and upload grants add their own bounds above this shared lifecycle.

Connect generation to the action that needs proof

Wonder Todos pairs ASSIGN and CHANGE_STATUS in tasks.resources-config.ts. Its application-owned TasksManager_ConsumableTokenType.TASK_APPROVAL names the purpose (task_approval). The following is the generation block inside the existing ASSIGN API variant; its request declares assignedToUserId, and the operation requires ORG_MEMBER:

tokenGeneration: {
  tokenType: TasksManager_ConsumableTokenType.TASK_APPROVAL,
  consumptionMode: ConsumableToken_ConsumptionMode.SINGLE_USE,
  expiresIn: { value: 12, unit: DurationUnit.HOURS },
  grantedRoles: [{ roles: [CORE_ORG_ROLES.ORG_MEMBER], relatedPrimaryScope: ResourcePrimaryScope.ORGANIZATIONS }],
  userIdField: 'assignedToUserId',
  revokeExistingForSameRelated: true,
  createsOneOffSession: true,
  consumeAt: 'TARGET_OPERATION',
  targetingFields: { taskId: '_id' },
},

ConsumableToken_ConsumptionMode, DurationUnit, CORE_ORG_ROLES and ResourcePrimaryScope come from @wildo-ai/saas-models; the token-purpose enum belongs to the application. The generation helper reads the updated task: _id supplies the related record, assignedToUserId supplies the recipient and organizationId supplies the tenant. Reassignment replaces outstanding tokens of this purpose for that related record.

The matching CHANGE_STATUS variant already has its update request and organization-member role requirement. Its additional declaration is:

tokenAuthentication: {
  types: [TasksManager_ConsumableTokenType.TASK_APPROVAL],
  policy: TokenAuthenticationPolicy.ADDITIVE,
},

TokenAuthenticationPolicy is also exported by @wildo-ai/saas-models. ADDITIVE requires normal authentication plus the token; the token’s purpose, state and recipient are checked, and normal operation authorization still applies. ALTERNATIVE is a separate authoring choice that authenticates from the token context. Do not substitute it just to avoid supplying a signed-in session. In this ADDITIVE path, the recipient check does not compare the token’s task target with the task addressed by the request. An application that requires approval for exactly one record must enforce that match in its operation; targetingFields alone is not that authorization check.

Register the resource and deliver the generated secret

The task factory is registered under TasksManager_ResourceType.TASKS in tasks-manager.resource-configs.ts. The shared tasks-manager module contributes that map through resourceConfigurations, alongside resourceFieldIdentifiers and resourceRelationships. These declarations participate in the normal resource pipeline; they are not a second standalone token router.

Token generation also supplies additionalContext.tokenValue to configured notification dispatch. An authored notification/template must consume that value and deliver it to the intended recipient through a configured channel. Declaring generation does not by itself author an approval email, and the ordinary task-update response is not a secret-retrieval API. Keep the recipient selection, template and delivery configuration aligned with the generation branch.

This particular example opts into a one-off session and therefore uses the engine’s twelve-hour mint ceiling. With TARGET_OPERATION, exchange retains the consumable token for the target action. The exchanged session uses the account’s normal authorization; the targeting fields do not make its access JWT record-only. See one-off session links for that separate session contract. A workflow that does not need login should not opt into session creation merely to carry an action token.

Invoke the paired operations

For an existing task, use the application API base, organization/task IDs and an authorized member session. The assignment request supplies the recipient ID:

curl --fail-with-body -X PUT "$BACKEND_URL/organizations/$ORGANIZATION_ID/tasks/$TASK_ID/assign" \
  -H "Authorization: Bearer $MEMBER_TOKEN" \
  -H 'Content-Type: application/json' \
  --data "{\"assignedToUserId\":\"$ASSIGNEE_ID\"}"

After the configured delivery reaches the assignee, APPROVAL_TOKEN is that generated secret and ASSIGNEE_ACCESS_TOKEN is their authenticated session. BACKEND_URL includes the API prefix. Change the same task’s status with both credentials:

curl --fail-with-body -X PUT "$BACKEND_URL/organizations/$ORGANIZATION_ID/tasks/$TASK_ID/change-status" \
  -H "Authorization: Bearer $ASSIGNEE_ACCESS_TOKEN" \
  -H "x-consumable-token: $APPROVAL_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"status":"completed","reason":"Approved by the assignee"}'

For ADDITIVE, put the secret in x-consumable-token; a consumable_token query parameter does not satisfy that requirement. Inspect the persisted status after success, then repeat with the spent token: it must no longer authorize a second consumption. Also check a missing token and a different recipient, alongside the successful authorized request. A failed attempt is only meaningful after the positive path has established that the task, session and delivery are valid.

Recover accounts and manage access changes

Recover access through your inbox Feature

Let people replace a forgotten password using an expiring email link. Wildo checks the replacement against the account’s policy and withdraws earlier tokens when the change completes.

The request response does not reveal whether an address belongs to an active account.

Example: Replace a forgotten password

A person requests recovery, opens the email and sets a new password. Their older sessions must authenticate again.

A person requests a reset, receives a time-limited email link and chooses a new password.
For engineers
Keep request and completion separate

The request handler returns the same response for unknown, eligible and ineligible accounts. ACTIVE and PENDING_VERIFICATION accounts can receive a single-use password-reset token by transactional email; suspended, inactive and deleted accounts do not receive it. Auth_PasswordLost supplies the standard request and completion interface.

Apply the current account policy

The completion service consumes the token, verifies its purpose and resolves the account’s effective password policy. After validation, the actual credential-changing sequence is:

const newPasswordHash = await this.credentialVerifier.hashPassword(newPassword, this.appConfigService.config.auth.passwordHashing);
await this.tokenIssuer.invalidateAllTokensForUser(userId);
await this.credentialVerifier.updatePasswordHash(ec, userId, newPasswordHash);
await this.consumableTokenService.revokeTokensByRelatedId(userId, CoreConsumableTokenTypes.PASSWORD_RESET);

The hash is derived using configured password hashing. Earlier account tokens are withdrawn before the credential is written, and sibling password-reset links are revoked. A custom recovery screen should submit to this completion operation rather than update a user record directly.

Make the retry behavior understandable

The token is consumed before password-policy validation. A rejected replacement can therefore require another recovery email; the same link is not a reusable validation session. Present the known password requirements before submission and preserve the resend path.

A pending-verification account can receive recovery mail without becoming active. Restoring a suspended or deactivated account remains a separate lifecycle action. The completion endpoint limits requests to 10 per minute per IP before parsing or hashing; the request endpoint has its own throttles.

Require a password reset without seeing the new password Feature

An administrator can initiate a password reset for another account, withdraw its existing sessions and send the replacement link to the account owner.

The action records who requested it and why. The administrator receives confirmation, not a link that lets them choose someone else’s password.

Example: Respond to a suspected credential leak

An administrator provides a reason for resetting a member’s password. Existing sessions are withdrawn and the member receives the reset email.

An administrator requests a reset; an email leads the account holder to choose a new password.
For engineers
Use the dedicated administrative operation

The FORCE_PASSWORD_RESET variant is application-super-admin gated and explicitly admits a different target user. Its request and response contracts are selected here from users.shared.resources-config.schemas.ts:

requestDto: z.object({
  reason: z.string().min(1).max(500)
}),
customResponseDto: z.object({
  expiresAt: z.date(),
  sessionsRevoked: z.boolean()
}),

The custom implementation requires one addressed account, an attributable caller and a non-empty reason. Self-reset is refused here; the account owner’s normal password-change path proves their current credential instead.

Observe what the operation actually returns

expiresAt describes the emailed link and sessionsRevoked confirms withdrawal. Neither the reset token nor URL is returned to the administrator. The shared password-reset service sends to the target user’s email using the transactional template and resolved locale.

Separate containment from completion

Existing tokens are invalidated before the forced-reset flow completes. The user then follows the mail and chooses a password that meets their effective policy. Starting the reset is not evidence that the owner has received the email or changed the password yet. Delivery configuration and operational monitoring therefore remain necessary alongside the account action.

The backend refuses a deleted target. Reset initiation does not reactivate a suspended account or assign new roles; those are separate lifecycle decisions.

Manage account access through deliberate actions Feature

Give administrators named actions for changing roles, suspending access and restoring accounts. Wildo carries those actions through their permissions, account transitions and audit behavior.

A suspension, a person’s own deactivation and account deletion mean different things. Keeping them separate makes both the interface and the resulting access easier to understand.

Example: Restore an account for the right reason

An administrator unsuspends an imposed hold. A voluntarily deactivated account uses a different reactivation action with a required reason.

An administrator oversees an account moving from active to suspended and then restored.
For engineers
Choose the lifecycle action rather than patching status

The user resource declares separate operations for role assignment/revocation, suspension, unsuspension, reactivation and forced password reset. Generic updates do not stand in for those transitions.

ActionIntended transition
SuspendAn administrator imposes a halt on access
UnsuspendLift that imposed suspension
Reactivate userRestore an account its owner deactivated
Assign or revoke rolesChange authority through the role-management path
Force password resetWithdraw sessions and send the owner a reset link
Keep the actor and subject distinct

Administrative variants combine their role requirement with admitsCrossSubjectUserAdministration: true. That flag allows the target to be another user; it does not itself grant administrative authority. The same operation still has to satisfy its declared roles and any fresh-proof requirement.

The destructive user operation makes the combination explicit:

variantType: ResourceOperationVariantType.API_CALL,
isDefault: true,
roles: [CORE_APP_ROLES.APP_ADMIN_SUPER_ADMIN],
admitsCrossSubjectUserAdministration: true,
riskLevel: ResourceOperationRiskLevel.CRITICAL,
requiresStepUpAuthentication: true,

These are selected fields from the real user-deletion variant, with comments omitted. Presentation risk and step-up enforcement are separate settings.

Preserve continuity and the record of change

The shared transition machinery owns status changes and scheduled reactivation cleanup. Administrative floors prevent removing the last usable administrator. reactivate_user requires a reason because it reverses the owner’s chosen departure, while unsuspension has a different contract. Custom administration screens should invoke these operations and display their outcomes, not write the underlying status and role fields directly.

Give people control over their own account Feature

People can read their own account and choose to deactivate it through a dedicated self-service surface. Wildo keeps these actions separate from the administrative operations that manage other users.

Deactivation stops access. Returning from that state requires administrative reactivation; it is not an automatic pause-and-return flow.

Example: Leave an account deliberately

A person deactivates their account with an optional reason. The application preserves the distinction between their decision to leave and an administrator’s suspension.

An account can be deactivated from active to inactive; an administrator provides the return to active.
For engineers
Use the self-service resource for the current person

userSelf provides own-account reads and lifecycle actions over the user records. It does not expose arbitrary user-row updates. Profile and preference changes belong to their dedicated self-service resources.

The deactivation operation’s current request and availability condition are:

requestDto: z.object({
  reason: z.string().max(500).optional(),
}),
enabledCondition: ({ currentObject }) => {
  return currentObject.status === UserStatus.ACTIVE;
}

The shared transition moves ACTIVE to INACTIVE. The administrative continuity floor still applies, so the last usable administrator cannot bypass that protection by choosing the self-service route.

Explain the return path before deactivation

Normal authentication admits active accounts. Once deactivated, a person cannot simply sign back in to invoke self-reactivation. The administrative reactivate_user operation restores INACTIVE accounts with a documented reason. An imposed SUSPENDED account uses the distinct unsuspend operation.

Keep data-rights actions explicit

Account deactivation is not data erasure. This resource does not provide a self-delete or subject-export endpoint. Applications that offer those journeys need to connect their authorized data-rights process to the relevant services rather than relabel deactivation as deletion. This distinction preserves the user’s expectation and the lifecycle behavior behind the control.

Give people authority within clear boundaries

Decide what people and services may do, and where they may do it. Wildo connects roles, account membership and resource ownership to the operations that read and change your data.

The same model also covers the awkward moments: granting a role, linking a person from another account, handing over administration or letting support investigate a customer’s problem.

A request connects the caller, permitted action and customer scope before reaching a record.

Access rules that stay connected to the work

Delegate the right responsibilities

Roles inherit responsibilities, while grant checks stop ordinary administrators from assigning authority above their own.

Keep customers’ records separate

Authorized scope carries into reads, writes and declared membership checks, including lists of the accounts themselves.

Make exceptional access explicit

Administrative continuity and temporary support grants handle recovery without turning platform access into a standing tenant privilege.

Example: Assign work without crossing account boundaries

An administrator gives a supervisor the role needed to assign work. The operation checks that role, the task stays within its organization, and the assignee relationship requires an eligible member. A support operator investigating the account needs the separately declared access path and a usable temporary grant.

For engineers

Start with ownership and operation authority

A resource’s registered ownership relationships or scope anchor establish its primary scope. An operation declares its required roles. At request time, the framework combines those declarations with the authenticated principal’s memberships and other contextual authority before reaching the resource operation.

LayerQuestion it answers
Principal and membershipWho is calling, and in which account can they act?
Role hierarchy and operation rolesMay this caller use this action?
Addressed scope and repository confinementWhich records may this operation reach?
Declared reference membershipMay this record link to that person or organization?
Grant and continuity controlsIs this exceptional crossing or administrative change admissible now?

A matching role does not replace scope confinement. A successful existence check does not replace membership eligibility. Keeping these decisions separate is what lets the same resource operations serve people, services and declared administrative work.

Add relationship eligibility where existence is insufficient

A user can exist in the application without belonging to the task’s organization. This declaration from Wonder Todos adds the membership question to its assignee relationship; context display options are omitted here.

createResourcesRelationship(
  TasksManager_ResourceType.TODOS, CoreResourceType.USERS,
  ResourceRelationshipCardinality.MANY, ResourceRelationshipCardinality.ONE,
  {
    nature: RelationshipNature.REFERENCE,
    foreignKeyField: 'assignedToUserId',
    parentResourceRequirement: ResourceParentResourceRequirement.OPTIONAL,
    accessScopeStrategy: ResourceRelationshipAccessScopeStrategy.OPTIONAL_CONTEXT,
    scopeMembership: true,
  }
),

The write-integrity path checks the membership junction before accepting the foreign key. For a different eligibility relationship, such as a rota, name the junction and its scope/target columns. Operation-level referenceConstraints can further narrow the eligible population.

Keep elevated actions inside their intended contract

Role-grant ceilings compare requested authority with the caller’s effective roles; the surrounding operation gate still determines where the write is allowed. Administrator floors examine the population left after a change, including whether the remaining accounts are usable.

A platform crossing requires both an operation that admits it and a usable grant into the target account. Customer approval is configurable; ownerless recovery and application-wide directory access have their own explicit paths. The support-access sequence connects the operation declaration, stored customer approval setting and two callers; ownership recovery shows the subsequent membership-addressed request. Do not replace these controls with direct repository or database writes when an ordinary operation refuses.

Continue with role hierarchy, tenant confinement and temporary support access for the corresponding declarations and lifecycle details.

Define who may do what

Give each role the right responsibilities Mechanism

Roles describe what a person or service may do. Wildo lets your application extend the built-in roles and inherit their responsibilities, so you can express your own team structure without rebuilding ordinary access checks.

The role travels with its scope: being an administrator of one organization does not make someone an administrator of another.

Example: A supervisor can do the work they oversee

A project supervisor inherits the manager role. Operations available to managers remain available to that supervisor, while ownership changes can require a higher role.

Member, manager and administrator roles form a hierarchy with increasing read, assign and manage permissions.
For engineers

Register custom roles in the application role configuration. inheritFrom supplies the existing responsibilities; relatedPrimaryScope selects the role table in which they are meaningful. Wonder Todos declares both an application manager and an organization supervisor:

This implementation excerpt from roles.ts shows the decision in context; explanatory source comments are omitted.

import { RolesConfiguration, ResourcePrimaryScope, CORE_APP_ROLES, CORE_ORG_ROLES } from "@wildo-ai/saas-models";

export const CUSTOM_ROLES_CONFIGURATION: RolesConfiguration = {
  'CUSTOM_APP_MANAGER': {
    role: 'CUSTOM_APP_MANAGER',
    inheritFrom: CORE_APP_ROLES.APP_USER,
    isSystemRole: false,
    relatedPrimaryScope: ResourcePrimaryScope.APPLICATION
  },
  'CUSTOM_ORG_SUPERVISOR': {
    role: 'CUSTOM_ORG_SUPERVISOR',
    inheritFrom: CORE_ORG_ROLES.ORG_MANAGER,
    isSystemRole: false,
    relatedPrimaryScope: ResourcePrimaryScope.ORGANIZATIONS
  }
};
Register the role map with the shared application module

A role map must reach startup before operations can use its hierarchy. Wonder Todos’ shared-lib/src/engine/index.ts contributes it through the existing engine module:

import type { SharedSaaSModule } from '@wildo-ai/saas-models';
import { CUSTOM_ROLES_CONFIGURATION } from './roles';

const engineSharedModule: SharedSaaSModule = {
  moduleId: 'engine',
  kind: 'engine',
  customRoles: CUSTOM_ROLES_CONFIGURATION,
  // Keep the module's existing feature, milestone and product contributions.
};

export default engineSharedModule;

This selected module excerpt is the contribution point. In Wonder Todos, shared-lib/src/modules-registry.shared.ts includes engineSharedModule in sharedModules and passes that list to buildSharedSaaSModulesRegistry. Retain that existing assembly. Declaring the constant in an unreferenced file does not register roles. Membership assignment and an operation’s required roles are separate choices: registration makes the hierarchy available, it does not grant it to every account.

Follow the declaration into authorization

Application startup registers this configuration with initializeRolesWithCustom. The authorizer expands the caller’s role through the configured inheritance chain before comparing it with an operation’s roles. This means an inherited manager responsibility remains available without copying the entire role list onto every member.

DecisionDeclaration or runtime input
What responsibilities a role includesinheritFrom in the role configuration
Where the role appliesrelatedPrimaryScope and the caller’s membership
Which role an action requiresThe operation’s roles
Which records the caller can reachScope, membership and resource authorization

A matching role is one part of admission. Tenant confinement and reference eligibility still apply. APP_PUBLIC is an operation declaration that removes a role requirement; it is not a role to grant to an account. Use role grants to control who may assign the roles you define.

Keep role grants within the caller’s authority Guarantee

People can delegate responsibilities they hold without being able to create a more powerful account or integration. Wildo checks requested roles when members and machine credentials receive their authority.

Inherited responsibilities count too: an administrator can grant the member role they already include, while a higher owner role remains outside their authority.

Example: An administrator creates an integration

An organization administrator gives a service the member role it needs. Asking for the owner role is refused before the credential is created.

A manager can grant lower roles while higher authority remains above a boundary.
For engineers

The OAuth-client mint handler checks the caller-authored roles before generating secret material. The core create still performs persistence, and the postfix returns the plaintext secret once:

This implementation excerpt from oauth-clients.custom-impl.backend.service.ts shows the decision in context; explanatory source comments are omitted.

export function buildOAuthClientMintHandlers(scope: ApiKeyScope, roleHierarchyResolver: RoleHierarchyResolver): OAuthClientImplHandlers {
  return {
    prefixCoreOperations: async (_id, input, executionContext, _operationPath, utils) => {
      assertRequestedRolesWithinCallerCeiling(executionContext, (input as { roles?: string[] }).roles, utils.errorBuilder, roleHierarchyResolver);
      const { plainSecret, secretPrefix, hashedSecret } = generateOAuthClientSecretMaterial(scope);
      PLAINTEXT_SECRET_BY_EC.set(executionContext, plainSecret);
      return { ...(input as Record<string, unknown>), secretPrefix, hashedSecret };
    },
    postfixCoreOperations: async (_id, createdClient, executionContext, _operationPath, _utils) => {
      if (!createdClient || typeof createdClient !== 'object') return createdClient;
      const plainSecret = PLAINTEXT_SECRET_BY_EC.get(executionContext);
      PLAINTEXT_SECRET_BY_EC.delete(executionContext);
      if (!plainSecret) return createdClient;
      return { ...(createdClient as Record<string, unknown>), plainSecret };
    },
  };
}
Extend the same check to application resources

The framework wires assertRequestedRolesWithinCallerCeiling into its membership, unit-assignment, application-role and credential creation paths. If an application introduces another resource whose caller-settable roles confer authority, call the assertion from its create and roles-changing update handlers too.

Use createRoleHierarchyResolver to read the live, application-configured hierarchy. Pass the roles as the caller authored them, before adding policy defaults. Otherwise a default role supplied by the framework would be mistaken for a privilege the person asked to grant.

Keep scope and privilege checks separate

The ceiling compares effective roles across the caller’s organization-wide memberships. A unit-scoped administrator role does not become organization-wide grant authority. The surrounding operation authorization answers where the caller may write; this check answers how high they may grant.

Internally initiated provisioning and an application-scoped super-administrator are deliberate exceptions. Credential roles are fixed at creation: rotation changes secret material, not the roles the credential carries.

Keep a working administrator in place Guarantee

An administrative change should not leave the application with nobody able to manage it. Wildo refuses changes that would remove the last usable super-administrator and explains the recovery step.

The check considers whether an account can actually serve as an administrator, not only whether a role name remains in the database.

Example: Hand over administration before leaving

Before removing the final administrator’s authority, appoint another usable administrator. The same request can then proceed without leaving the application stranded.

An application administrator hands responsibility to another administrator, with a pause at the handover.
For engineers

Role and account-status changes use the transition guard; removals use the removal guard. Both reach the same population check. This part of that check shows how it excludes the accounts being changed:

This implementation excerpt from super-admin-floor.backend.utils.ts shows the decision in context; explanatory source comments are omitted.

export async function assertSuperAdminFloorPreserved(params: {

  subjectUserIds: readonly string[];
  cause: SuperAdminFloorReductionCause;
  executionContext: ExecutionContext<any>;
  repositoriesRegistry: RepositoriesRegistryHandlerBackendService;
  errorBuilder: ErrorBuilderBackendService;
  conferringRoles: readonly string[];
  operationIdentifier: string;
}): Promise<void> {
  const { subjectUserIds, cause, executionContext, repositoriesRegistry, errorBuilder, conferringRoles, operationIdentifier } = params;

  const remainingUsableSuperAdmins = await countOtherUsableSuperAdmins({
    excludedSubjectIds: subjectUserIds,
    executionContext,
    repositoriesRegistry,
    errorBuilder,
    conferringRoles,
    operationIdentifier,
  });

  if (remainingUsableSuperAdmins >= SUPER_ADMIN_FLOOR_MINIMUM) return;

  throw errorBuilder.buildError(ErrorType.CONFLICT, executionContext, {
    context: {
      code: AdministrativeContinuityErrorCode.LAST_APPLICATION_SUPER_ADMIN,
      reason: SUPER_ADMIN_FLOOR_REFUSAL_REASON,
      message:
        'Refused: this would leave the application with no usable super-administrator. '
        + SUPER_ADMIN_FLOOR_REMEDY,
      cause,
      operationIdentifier,
      subjectUserIds: [...subjectUserIds],
      remainingUsableSuperAdmins,
      requiredUsableSuperAdmins: SUPER_ADMIN_FLOOR_MINIMUM,
      conferringRoles: [...conferringRoles],
      usableStatuses: [...ADMINISTRATIVELY_USABLE_USER_STATUSES],
    },
  });
}
Treat the refusal as a state conflict

The caller may have all the required permissions and still be unable to perform this change. The response is a conflict with LAST_APPLICATION_SUPER_ADMIN, because the problem is the resulting administrator population. Promoting another usable account is the remedy; acquiring another permission is not.

Custom roles that confer super-administrator authority participate through the live role hierarchy. A disabled or otherwise unusable account must not be counted as the fallback simply because its stored roles still look powerful.

Preserve continuity on the right lifecycle path

The account-role administration handlers call the guard when authority or usability decreases. Subject erasure has a separate continuity path: do not replace a privacy erasure workflow with a blanket refusal. Organization ownership has its own last-owner rule, because tenant ownership and application administration are different responsibilities.

Keep ownership and requests aligned

Put information in the scope it belongs to Mechanism

Some information belongs to the application, some to a customer organization, some to one person and some to a visitor who has not registered. Wildo makes that ownership an explicit part of the resource definition.

Those distinctions let shared settings, private preferences and tenant business records coexist without treating them as the same kind of data.

Example: Keep personal preferences beside team records

A person’s preferences belong to that person. The projects they work on belong to their organization, and the application’s configuration belongs to the deployment.

Four separate cards represent application, customer, personal and visitor scopes.
For engineers

ResourcePrimaryScope names the resolved scope. Application authors declare ownership through relationships; the resource factory derives resourcePrimaryScope from that declaration. Scope-root resources anchor themselves, and polymorphic resources receive the relationship for their selected scope variant. Operation roles then decide who can act within the resolved scope.

ScopeOwnershipTypical use
APPLICATIONThis application deploymentShared configuration
ORGANIZATIONSOne customer organizationBusiness records
USER_SELFOne authenticated personPersonal preferences
ANONYMOUSOne visitor sessionWork started before registration
Declare the ownership that determines the scope

Wonder Todos registers this relationship alongside its resource definitions. The parent is an organization, the child is a todo, and isPrimaryScope identifies the ownership relationship used to derive the child’s scope. Source: tasks-manager.relationships.ts; source comments omitted.

createResourcesRelationship(
  CoreResourceType.ORGANIZATIONS, TasksManager_ResourceType.TODOS,
  ResourceRelationshipCardinality.ONE, ResourceRelationshipCardinality.MANY,
  {
    nature: RelationshipNature.COMPOSITION,
    isPrimaryScope: true,
    foreignKeyField: 'organizationId',
    contextPolicy: {}
  }
)

With this registered relationship, the factory resolves organization ownership and its foreign-key field. Another relationship, such as the todo’s assignee, remains a reference; it does not replace the primary owner. An ordinary resource without a resolvable primary scope is rejected during configuration instead of silently receiving a broader scope.

Let a draft belong to a person or a visitor

Wonder Todos’ draft notes demonstrate USER_SELF and ANONYMOUS without changing the business object. The schema declares nullable userId and anonymousUserId foreign keys, both excluded from ordinary updates. The resource opts into anonymous ownership with isAnonymizable: true and chooses transpositionPolicy: ResourceTranspositionPolicy.ADD for the later account handoff.

Its authored relationship starts from the signed-in person. This is the relevant declaration from tasks-manager.relationships.ts, including the child lifecycle decision:

createResourcesRelationship(
  CoreResourceType.USERS, TasksManager_ResourceType.DRAFT_NOTES,
  ResourceRelationshipCardinality.ONE, ResourceRelationshipCardinality.MANY,
  {
    nature: RelationshipNature.COMPOSITION,
    isPrimaryScope: true,
    foreignKeyField: 'userId',
    parentResourceRequirement: ResourceParentResourceRequirement.OPTIONAL,
    accessScopeStrategy: ResourceRelationshipAccessScopeStrategy.REQUIRES_CONTEXT,
    childOperations: { lifecycle: { onParentDelete: { enabled: true, mode: ChildLifecycleMode.IMMEDIATE } } },
    contextPolicy: {},
  }
)

The relationship helper and enum vocabularies come from @wildo-ai/saas-models; TasksManager_ResourceType is application-owned. OPTIONAL accommodates a visitor-owned row without a userId; it does not make another person’s records public. The anonymizable resource factory supplies the anonymous ownership relationship. Application authors do not hand-author a second unrelated draft resource.

The draftNotes_ResourceConfiguration_InitializationFactory is registered in the tasks-manager resource factory map, and the shared module contributes that map together with its relationships and field identifiers. Its ordinary create/read/list/update/delete API variants accept APP_USER and APP_ANONYMOUS; ownership confinement still applies to each caller. Allowing both roles does not merge their records.

Observe the two ownership paths

With BACKEND_URL including the API prefix, an authenticated person lists their drafts using their own user ID and Bearer session:

curl --fail-with-body "$BACKEND_URL/users/$USER_ID/draft-notes" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN"

For an already established anonymous session, use its UUID in the path and its signed session token in the header. The UUID is an identifier, not a credential:

curl --fail-with-body "$BACKEND_URL/anonymous-users/$ANONYMOUS_UUID/draft-notes" \
  -H "x-anonymous-session: $ANONYMOUS_SESSION_TOKEN"

The standard client establishes and carries that session; anonymous sessions and transposition explains its lifecycle. Supply the application’s frontend-service header where its deployment requires it. Neither call turns a user-supplied owner field into authority.

Verify isolation using existing disposable drafts: each owner can read their own record; substituting another user or visitor’s path/record must not expose or mutate that record. Check the allowed read first so an unavailable route cannot masquerade as successful isolation. The existing scope-isolation-user-anon-hostile.e2e.ts exercises these separate ownership paths and re-reads storage after denied mutations.

Carry the scope through the operation

The framework resolves the scope root and contextual identifier, builds the caller’s execution context and uses the matching authorization path. A user-scoped resource is not made accessible to everyone merely because it sits in the same application database.

Organization units narrow authority inside an organization; they do not introduce a fifth primary scope. Likewise, a directory of users is an application administration surface, not the same interaction as reading one person’s own preferences.

Plan the visitor-to-account transition

Anonymous ownership can support work before registration. A resource’s transposition policy determines whether that work is reassigned, replaces existing work or is discarded when ownership moves. Treat that transition as a separate product decision from who may operate on the visitor’s data now.

See tenant confinement for organization records and anonymous sessions for the registration handoff.

Keep each customer’s records in their account Guarantee

Organization-owned records are confined through the framework’s resource and repository paths. Application code can work with the current organization’s data without rebuilding a tenant filter in every endpoint.

The organization comes from the authorized request context, while shared confinement rules serve both MongoDB and PostgreSQL.

Example: Two customers use the same project feature

Both organizations can list and edit projects through the same application code. Each request operates within its authorized organization, so one customer’s records do not become the other’s results.

A request reaches Acme's records while Northwind's records remain separate.
For engineers

Register the primary ownership relationship with the application’s resource relationships. The resource factory derives organization scope from it; resourcePrimaryScope is not a second author-maintained setting. In Wonder Todos, tasks-manager.relationships.ts contains this declaration (source comments omitted):

createResourcesRelationship(
  CoreResourceType.ORGANIZATIONS, TasksManager_ResourceType.TODOS,
  ResourceRelationshipCardinality.ONE, ResourceRelationshipCardinality.MANY,
  {
    nature: RelationshipNature.COMPOSITION,
    isPrimaryScope: true,
    foreignKeyField: 'organizationId',
    contextPolicy: {}
  }
)

The todo schema supplies the organizationId foreign-key field. The relationship connects that field to the organization parent and makes it the primary ownership axis. Register it before initializing the resource configuration, alongside the resource’s operations and other relationships.

When an authorized caller lists todos through the organization’s contextual resource operation, the framework resolves that organization in the execution context and carries confinement into the repository query. The same declaration serves both persistence adapters. Application code still supplies the operation’s roles and business filters; it does not infer tenant authority from a submitted organization ID.

The shared confinement function checks the authenticated organization and the operation’s declared crossing policy before adding the top-level ownership predicate:

This implementation excerpt from initiator-organization-confinement.backend.ts shows the decision in context; explanatory source comments are omitted.

export function assignInitiatorOrganizationConfinementToFilter<TFilter extends object>(params: {
  readonly filter: TFilter;
  readonly executionContext: InitiatorOrganizationConfinementSignal;
  readonly blacklistedFields: ReadonlySet<string>;
  readonly mainSchema: unknown;
  readonly schemaPaths: InitiatorOrganizationConfinementPathResolver;
  readonly isFieldPersisted: (fieldName: string) => boolean;
}): boolean {
  const initiatorOrganizationId = params.executionContext.initiatorIds?.organizationId;
  if (!initiatorOrganizationId) {
    return false;
  }

  if (params.executionContext.operation?.admitsCrossTenantPlatformAdministration === true) {
    return false;
  }

  if (
    params.blacklistedFields.has(INITIATOR_ORGANIZATION_CONFINEMENT_FIELD)
    || !params.isFieldPersisted(INITIATOR_ORGANIZATION_CONFINEMENT_FIELD)
  ) {
    return false;
  }

  const organizationPaths = params.schemaPaths.findNestedFieldPathsFromZodSchema(
    params.mainSchema,
    INITIATOR_ORGANIZATION_CONFINEMENT_FIELD,
  ).filter((fullPath) => !fullPath.includes('.'));

  let applied = false;
  organizationPaths.forEach((fullPath) => {
    if (!params.schemaPaths.hasNestedPath(params.filter, fullPath)) {
      params.schemaPaths.setNestedValue(params.filter, fullPath, initiatorOrganizationId);
      applied = true;
    }
  });

  return applied;
}
Follow authority, rather than trusting a payload

The execution-context creator obtains memberships and removes authority from organizations that are no longer operational. The normal create path supplies contextual ownership fields; client data is not the authority for which tenant owns the new record.

Both persistence adapters call the shared confinement authority. A nested field that happens to be named organizationId is ordinary data unless it is the declared top-level ownership axis; filtering every matching field name would hide legitimate records.

Use the declared exceptional path

A deliberately admitted cross-tenant platform operation can step outside normal confinement. That is an operation-level declaration with its own admission requirements, not a global super-administrator shortcut. Arbitrary direct database access is outside the resource path and must not be mistaken for an authorized resource operation.

Organization listings need an additional rule because the organization is the scope root itself: see account-list confinement.

Stop requests from choosing another customer’s account Guarantee

An authorized write must remain in the account it was authorized for. Wildo reconciles the caller’s identity, addressed scope and server-managed ownership fields so request data cannot quietly move the operation into another tenant.

This protects the create and update path as well as the records returned by a query.

Example: A request carries the wrong organization

An integration authenticated for one organization calls a route naming another. The mismatch is refused instead of creating a record under the organization named in the request.

An Acme write reaches Acme storage; a Northwind-labelled write is stopped as a mismatch.
For engineers

A machine token names its scope. The execution-context creator compares the addressed scope with that authenticated value before constructing the request context:

This implementation excerpt from execution-context-creator.backend.service.ts shows the decision in context; explanatory source comments are omitted.

const urlScopeId = machineScope === ResourcePrimaryScope.ORGANIZATIONS
          ? initiatorCriticalParamsValue.organizationId
          : initiatorCriticalParamsValue.applicationId;
        if (urlScopeId && urlScopeId !== machineToken.scopeId) {
          throw this.errorBuilder.buildError(ErrorType.AUTHORIZATION, undefined,
            { customMessageReference: ErrorCustomMessageReference.AUTHORIZATION_MACHINE_SCOPE_MISMATCH,
              context: { reason: 'machine_scope_url_mismatch', tokenScope: machineScope } });
        }

        const machineCredential = { authMethod: MachineAuthMethod.OAUTH_CLIENT, credentialId: machineToken.clientId };
        const initiatorIds: ExecutionContext_InitiatorIds = machineScope === ResourcePrimaryScope.ORGANIZATIONS
          ? { ...this.extractInitiatorIds(initiatorCriticalParamsValue), organizationId: machineToken.scopeId, machineCredential }
          : { ...this.extractInitiatorIds(initiatorCriticalParamsValue), applicationId: machineToken.scopeId, machineCredential };
Keep ownership fields server-authored

Generated operation input shapes exclude contextual ownership fields from normal caller-controlled data. The resource operation path then enriches the write using its resolved context. Build scoped API calls with the intended organization in the route and ordinary business fields in the payload; do not rely on a payload organizationId to establish authority.

InputResponsibility
Authenticated principalEstablishes the caller and its authority
Addressed route/contextIdentifies the scope and resource being requested
Business payloadSupplies the values the operation allows the caller to change
Contextual enrichmentSupplies server-managed ownership fields

The exact refusal depends on the stage: invalid identity, inaccessible scope and disallowed input are different cases. Do not interpret an ignored extra property as proof that it was trusted.

A reference inside the payload still has its own eligibility question. Membership-constrained relationships prevent a valid tenant write from linking an ineligible person or partner.

Link records to eligible people and partners Mechanism

A referenced person or organization can exist without being eligible for this record. Wildo can require the declared membership or partnership before accepting the link.

That keeps relationships meaningful: an assignee belongs to the account, an escalation contact belongs to its rota, or a partner belongs to an approved relationship.

Example: Assign work to an account member

A task can refer to a user only when that user has the required membership in the task’s organization. Knowing somebody’s user ID is not enough.

An Acme task can reference a member, while its attempted reference to an outsider is refused.
For engineers

This is the task-assignee declaration in Wonder Todos. scopeMembership: true asks the registry to resolve the organization-membership junction. The context settings separately control the related information returned with the task:

This implementation excerpt from tasks-manager.relationships.ts shows the decision in context; explanatory source comments are omitted.

createResourcesRelationship(
    TasksManager_ResourceType.TODOS, CoreResourceType.USERS,
    ResourceRelationshipCardinality.MANY, ResourceRelationshipCardinality.ONE,
    {
      nature: RelationshipNature.REFERENCE,
      foreignKeyField: 'assignedToUserId', // Explicit for Edge Case #1 - multiple FKs to same type
      parentResourceRequirement: ResourceParentResourceRequirement.OPTIONAL,
      accessScopeStrategy: ResourceRelationshipAccessScopeStrategy.OPTIONAL_CONTEXT,
      scopeMembership: true,
      contextPolicy: {
        objectMode: ContextPolicy_ObjectMode.SUMMARY,
        operationOverrides: {
          [CoreResourceOperation.READ]: { objectMode: ContextPolicy_ObjectMode.FULL },
          [CoreResourceOperation.LIST]: { enabled: false },
          [CoreResourceOperation.SEARCH]: { enabled: false },
        }
      }
    }
  ),
Use an explicit junction when membership means something else

For a rota or partnership, set scopeMembership to an object naming the junction. junctionScopeField and junctionTargetField identify its columns when they cannot be inferred. The target’s existence and the junction’s existence answer different questions; both matter.

Create and update integrity checks evaluate referenced foreign keys before storing the write. An operation can add referenceConstraints to tighten eligibility, such as requiring a particular membership status. Related-record resolution uses the declared membership semantics as well.

Separate who may assign from who may be assigned

Wonder Todos’ assign-lead operation uses the same assignedToUserId relationship shown above and declares an active organization administrator as its eligible target. The caller must also be an administrator. The example below uses built-in organization roles: permission to make the change does not make every selected person eligible.

This is the operation declaration from todos.resources-config.ts, with comments omitted. Todos_Operations belongs to the application; the other enums come from @wildo-ai/saas-models, and z comes from zod.

[Todos_Operations.ASSIGN_LEAD]: {
  variants: [
    {
      variantType: ResourceOperationVariantType.API_CALL,
      isDefault: true,
      roles: [CORE_ORG_ROLES.ORG_ADMIN],
      riskLevel: ResourceOperationRiskLevel.MEDIUM,
      resourceOperationLike: CoreResourceOperation.UPDATE,
      requestDto: z.object({
        assignedToUserId: z.string().min(1),
      }),
      referenceConstraints: {
        assignedToUserId: {
          qualifyingStatuses: [OrganizationMemberStatus.ACTIVE],
          requiredRoles: { scope: ResourcePrimaryScope.ORGANIZATIONS, roles: [CORE_ORG_ROLES.ORG_ADMIN] },
        },
      },
    },
  ],
},

Place this inside the resource’s operationsConfiguration. The shared tasks-manager module contributes its factory map through resourceConfigurations and its relationship list through resourceRelationships. Both are needed: the relationship identifies the membership junction; this operation adds the eligibility criteria. Merely adding an input field does not declare that relationship.

For an existing todo, call the addressed operation with the organization’s administrator session. API_BASE includes the backend API prefix; ASSIGNEE_USER_ID is the selected user’s ID, not their membership ID.

curl --fail-with-body --request PUT "$API_BASE/organizations/$ORGANIZATION_ID/todos/$TODO_ID/assign-lead" \
  --header "Authorization: Bearer $ADMIN_ACCESS_TOKEN" \
  --header 'Content-Type: application/json' \
  --data "{\"assignedToUserId\":\"$ASSIGNEE_USER_ID\"}"
Selected person using built-in rolesResult with an authorized caller
Active administrator in this organizationEligible; verify the stored assignedToUserId after the update
Active owner in this organizationEligible through role inheritance: owner includes administrator authority
Member holding only ORG_MEMBER in this organizationRefused by this operation’s role requirement on the target
Administrator belonging only to another organizationRefused: the required membership is in the todo’s organization
Membership outside the qualifying statusRefused even if the membership carries an administrator role

Run the permitted case before testing refusals, and confirm refused writes leave the previous assignee unchanged. The ordinary assign operation on this resource does not add these status/role criteria: it retains the relationship’s membership check. If several operations must enforce the stricter policy, declare it on each applicable variant.

Target roles are resolved against the application’s current role definitions in the declared scope. A custom role can qualify through its inheritance; an unknown role grants nothing. Related-record search can offer members who do not meet a stricter write policy, so handle an assignment refusal even when the person appeared in a picker.

Make exceptions deliberate

This policy is opt-in on the relationship. An organization-less target without it may receive an existence check rather than a membership check. A variant that explicitly waives membership changes the contract for that variant; do not use that escape hatch to make an ordinary assignment work around an incomplete declaration.

Show people the accounts they belong to Guarantee

Account lists need their own boundary: an organization record is the account itself, rather than a record carrying a parent account ID. Wildo confines those collections to the caller’s organization-wide memberships.

A requested filter can narrow that set, but cannot turn it into a directory of other customers.

Example: Filter the account switcher

A person belonging to accounts A and B asks for B and C. The confined result can include B; the requested ID for C does not create membership.

Alex sees Acme and Northwind under My organizations, while unrelated Contoso stays outside the list.
For engineers

The same scope-root authority is used by authorization and repository filtering. After identifying a governed organization collection operation, it derives the allowed IDs from the caller’s organization-wide role entries and intersects the requested IDs:

This implementation excerpt from scope-root-collection-confinement.backend.ts shows the decision in context; explanatory source comments are omitted.

function intersectRequestedIdsWithMembership(requested: unknown, membershipIds: readonly string[]): string[] {
  if (requested === undefined || requested === null) return [...membershipIds];

  const membership = new Set(membershipIds);

  if (typeof requested === 'string') {
    return membership.has(requested) ? [requested] : [];
  }

  if (Array.isArray(requested)) {
    return requested.map(String).filter((id) => membership.has(id));
  }

  if (typeof requested === 'object') {
    const operators = Object.keys(requested as Record<string, unknown>);
    const inValue = (requested as { $in?: unknown }).$in;
    if (operators.length === 1 && operators[0] === '$in' && Array.isArray(inValue)) {
      return inValue.map(String).filter((id) => membership.has(id));
    }
    return [];
  }

  return [];
}
Know which operations are set-shaped

The confinement covers list, search, count, update-many and delete-many operations on the organization root. Addressed reads have a separate per-record authorization path. A unit-only role does not become permission to enumerate the whole organization.

Requested filterResult within memberships A and B
No ID restrictionA and B
ID BB
IDs B and CB
An unsupported ID operatorNo matching IDs

The portable empty-set predicate produces no results on either database adapter. A caller-supplied condition is never a reason to drop the membership restriction.

Distinguish tenant lists from application directories

Other scope roots have explicit dispositions: the deployment application row, the person directory and anonymous sessions do not share the same tenant-membership filter. Application-wide directory admission has its own elevation policy. Internally initiated work, verified callbacks and deliberately admitted cross-tenant operations also have distinct handling; an empty membership list must not silently empty a framework maintenance sweep.

Refuse access without exposing private records Guarantee

A refusal should not reveal whether another customer’s record exists. Wildo separates the decision visible to the caller from the detail needed to investigate the refusal.

The authorization trail preserves a signal for repeated attempts while keeping outward responses from becoming a private-record directory.

Example: An account member guesses a record ID

A record outside the person’s accessible scope does not become discoverable through a different “you cannot access this existing record” response. The internal refusal still has a reason for investigation.

Two unknown targets lead to the same unavailable response, with an audit record kept separately.
For engineers

Role checks that do not depend on the row can refuse before resolving it. A missing or scope-invisible addressed record uses the same outward not-found treatment. Internal storage faults remain faults rather than being rewritten as evidence that a record is absent.

CaseMeaning to preserve
Missing operation roleThe caller cannot use this action
Missing or inaccessible addressed recordNo accessible record can be returned
Repository or infrastructure failureThe operation failed; absence was not established
Keep a useful refusal trail

authorization-denial-audit.backend.utils.ts classifies authorizer-origin not-found outcomes as authorization refusals while excluding unrelated application 404s. The audit policy emits the first refusal and escalating occurrence counts, rather than writing an attacker-controlled number of rows.

The resource-operation authorization path uses this classifier to distinguish a concealed subject from an unrelated application failure. Exact excerpt from authorization-denial-audit.backend.utils.ts:

export function classifyAuthorizationDenial(
  error: unknown,
  phaseDefaultReason: ResourceOperationDenialReason,
): ResourceOperationDenialReason | undefined {
  if (!isWildoBackendError(error)) return undefined;

  if (error.type === ErrorType.AUTHORIZATION) {
    return error.customMessageReference === ErrorCustomMessageReference.AUTHORIZATIONS_MISSING_INITIATOR
      ? ResourceOperationDenialReason.MISSING_INITIATOR
      : phaseDefaultReason;
  }

  if (
    error.type === ErrorType.NOT_FOUND &&
    error.customMessageReference === ErrorCustomMessageReference.AUTHORIZATIONS_VALIDATION_FAILED
  ) {
    return ResourceOperationDenialReason.SUBJECT_NOT_RESOLVABLE;
  }

  return undefined;
}

The second branch recognizes only a not-found outcome marked by the authorizer. Other failures return undefined here; they keep their own error handling rather than entering the access-refusal series.

The counter is associated with the available user identity, resource, operation, variant and reason; calls without a user identity share the fallback bucket. Walking different target IDs therefore does not create an independent audit bucket for every guessed record.

Do not confuse observation with prevention

The normal authorization decision refuses the request. The audit volume policy does not rate-limit or lock the caller, and it does not normalize response timing. Security monitoring can act on the recorded pattern; application access rules remain the preventive control.

Make exceptional access deliberate

Make support access requested and temporary Feature

Support access can be tied to one customer account, a written reason and an expiry instead of being a standing privilege. Wildo requires a usable grant for declared platform crossings and lets the customer require approval.

The customer can see the request and withdraw the grant. Expired access stops being usable when its time window ends.

Example: Investigate a customer’s support request

An operator requests access to the named account for a short investigation. If customer approval is enabled, the operator waits for approval before using a declared support operation.

A support operator requests access to Acme for a limited time, with an explicit expiry.
For engineers

The resource exposes requestAccess, approve, deny and revoke. The request supplies justification and requested duration; operator identity, status and expiry are server-authored. Default duration is one hour and the server caps requests at four hours.

Admission checks the actual expiry, not whether a background process has relabelled the row:

This implementation excerpt from platform-access-grants.shared.schemas.ts shows the decision in context; explanatory source comments are omitted.

export function isPlatformAccessGrantUsable(
  grant: Pick<PlatformAccessGrant, 'status' | 'expiresAt'> | undefined,
  now: Date,
): boolean {
  if (!grant) return false;
  if (grant.status !== PlatformAccessGrantStatus.ACTIVE) return false;
  return grant.expiresAt instanceof Date
    ? grant.expiresAt.getTime() > now.getTime()
    : new Date(grant.expiresAt).getTime() > now.getTime();
}
Declare the operation that support may use

The crossing flag belongs to an API operation variant. The built-in ownership-repair operation keeps the role, scope permission and request together:

variantType: ResourceOperationVariantType.API_CALL,
isDefault: true,
roles: [CORE_APP_ROLES.APP_ADMIN_SUPER_ADMIN],
riskLevel: ResourceOperationRiskLevel.CRITICAL,
resourceOperationLike: CoreResourceOperation.UPDATE,
admitsCrossTenantPlatformAdministration: true,
requestDto: z.object({ justification: z.string().min(1).max(1000) }),

This is a selected variant fragment from organization-members.shared.resources-config.schemas.ts, not a standalone resource. Its operation enums and roles come from @wildo-ai/saas-models, with z from zod. The existing membership resource registers this operation. A custom support action needs its own registered resource operation, API variant and implementation; copying the flag alone creates neither a route nor business behavior.

Choose whether the customer must approve

platformAccessApprovalRequired is a field on the organization record, with a default of false. It is not an organization-type authentication override or a caller-supplied grant status. Set it through the authorized organization configuration path before support requests arrive. The request handler reads the stored organization posture and checks whether a usable owner can approve.

Request, decide, then perform the action

The following sequence follows Wonder Todos’ platform-access-grant lifecycle example. ORG_ID comes from the customer support case; ordinary organization listing is not a platform-wide customer directory. OPERATOR_TOKEN belongs to an application super-administrator. CUSTOMER_ADMIN_TOKEN belongs to a tenant administrator with authority to decide the request. Use a customer with approval required and a usable owner for this pending-approval example.

curl "$BACKEND_URL/organizations/$ORG_ID/platform-access-grants/request-access" \
  -H "Authorization: Bearer $OPERATOR_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"justification":"Investigate support case SUP-42","requestedDurationMinutes":30}'

curl -X PUT "$BACKEND_URL/organizations/$ORG_ID/platform-access-grants/$GRANT_ID/approve" \
  -H "Authorization: Bearer $CUSTOMER_ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"decisionReason":"Verified support case SUP-42"}'

Take GRANT_ID from the created grant record’s _id. Check its returned status and expiry: accepting the request does not mean access is active. With the example’s posture it waits for the tenant decision, and the operator’s crossing is refused while pending. The request operation itself has a narrow bootstrap exemption so an operator can ask without already holding the grant.

Approval uses the customer’s authority, not the operator’s platform crossing. For a refused request use the grant’s deny operation; to withdraw an existing grant use revoke. These are lifecycle operations, not edits to status or expiresAt. A usable grant is looked up when the operator calls the target operation; it is not a replacement login token to send as a Bearer credential.

Continue with the ownership-repair invocation for an actual declared target operation. The grant remains limited to its operator, organization and time window; it does not admit undeclared actions.

Apply both admission requirements

The target operation must declare that it admits cross-tenant platform administration, and the operator must have a usable grant for the target account. A grant does not turn every operation into a support door. Grant lookup failures do not restore standing access.

Account postureNew request
Approval not requiredAutomatically active, with the reason recorded
Approval required and usable owner presentWaits for a tenant administrator’s decision
No usable owner can approveRecovery path auto-approves with a distinct recorded reason

The last case prevents an ownerless account from becoming impossible to repair. Approval posture and owner usability are checked on the request path, not copied from the caller.

Separate account support from global administration

Approval and denial use the tenant’s own authority; the platform crossing declaration does not let an operator approve on that basis. Application-wide directory access has a separate grant and two-person approval policy because no single tenant can authorize reading a deployment-wide directory.

Let customers configure their own connections Feature

Each customer can have its own supported identity, provisioning and audit connections. Wildo places those settings in organization-owned resources so one account’s configuration does not become the application-wide default.

The settings interface follows the enabled features, while the resource and credential paths retain the organization context.

Example: Two customers bring different identity providers

One account configures its single sign-on connection while another uses its own. Their administrators manage the relevant organization settings instead of sharing one deployment credential.

Acme and Northwind each have separate connections and settings.
For engineers

The settings hub selects an organisation; its registered resource operations keep that owner in the request path and execution context. The backend validates who may use those operations. Selecting a customer in the interface does not itself grant authority over that customer’s resources.

For SSO, configuration and connections are separate resources under the same owner. Domain verification establishes who controls the sign-in domain; connection settings describe how the identity provider participates. Neither should be copied into an application-wide credential merely because the application hosts both customers.

Exercise an organization-owned configuration operation

For example, the SSO domain-claim operation lets the organization’s administrator begin proving a domain it controls. The organization already has its seeded SSO configuration; claiming a domain does not require creating a second configuration row.

This request follows sso-domain-ownership.e2e.ts. Set BACKEND_URL, ORGANIZATION_ID and the organization’s administrator ACCESS_TOKEN; replace the illustrative domain with one the customer controls.

curl -X PUT \
  "$BACKEND_URL/organizations/$ORGANIZATION_ID/organization-sso-config/claim-domain" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"domain":"customer.example"}'

The claim response names the domain, reports pending and returns dnsName plus the one-time dnsRecordValue. Publish that exact value as a TXT record at the returned name. Do not invent the challenge value or assume that the domain’s presence in the stored list means it is verified.

After publishing the record, ask the same scoped operation family to verify it:

curl --fail-with-body -X PUT \
  "$BACKEND_URL/organizations/$ORGANIZATION_ID/organization-sso-config/verify-domain" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"domain":"customer.example"}'
ResultWhat the administrator should understand
Claim returns pendingA challenge exists; publish its TXT record
Verification returns failedThe challenge was not established; inspect DNS and retry verification
Verification returns verifiedThe ownership check succeeded for this scope
The domain belongs to another verified scopeThe operation rejects the competing ownership claim

Read the response’s status, not only the HTTP status code. A completed verification request can return HTTP 200 with failed; --fail-with-body cannot detect that business result. The backend checks DNS and rechecks competing ownership at verification time, before granting routing authority. A claim alone does not authorize sign-on routing.

This illustrates the ownership contract: a public, named operation changes the selected organization’s configuration and returns the next action its administrator needs. Provider connection details and SSO enforcement remain separate settings; the general configuration update is an internal operation, not a public catch-all HTTP endpoint.

Configure the correct integration owner
SurfaceOrganization-owned configuration
Single sign-onSSO configuration and connections
Directory provisioningSCIM provisioning configuration and tokens
Audit streamingSIEM export configuration and delivery failures
Connected accountsProvider credentials
API accessOrganization API keys

Enable the relevant feature and configure the provider through its declared resource. A page appearing in settings is a consumer of that configuration, not proof that a provider is already connected or operating.

Preserve the distinction between personal and organization connections

A person’s connected account and an organization-owned credential represent different authority. Use the appropriate resource and runtime context rather than copying credential material into generic application settings.

This capability describes the integrations with organization-owned configuration. It does not imply that every external provider supports every ownership mode. See the individual sign-on, provisioning and machine-access guides for their setup and operation contracts.

Make security history usable

An audit trail should help someone understand what happened, who acted and what changed. Wildo connects that evidence to resource operations, authentication and administrative activity, then makes it available through scoped views and configured destinations.

Your application inherits the shared event path. You choose the additional business facts, retained field values and delivery settings that make the history useful to your customers.

Actions enter a primary audit history, with separate paths for customer inspection, security delivery and archival copies.

From an action to evidence someone can use

Record the meaningful change

Operation risk and evidence fields carry important changes into the trail, alongside sign-in, access and disclosure events.

Keep the context together

Actor, subject, scope and request correlation help explain an event without confusing who performed it with whom it concerns.

Put the history to work

Customers can inspect their records, receive selected events in their security tooling and recover deliveries. Optional archives and verified sinks extend the evidence path.

Example: Follow a permission change through review

An administrator grants a role. The operation records its subject and marked role values, the customer’s audit view makes it searchable, and a configured security collector receives the selected event. Correlation connects it to the originating request; a failed delivery can be inspected and replayed.

For engineers

Start with the inherited operation evidence

Declare the operation’s risk level and required authority separately. High-impact writes meet the automatic audit floor; a lower-risk operation can opt in. Mark the fields whose values explain the change, rather than retaining every input indiscriminately.

The API-key schema uses this actual declaration for authority:

roles: z.array(RolesSchema).default([]).isAuditEvidence(),

Its high-risk creation operation enters the resource service’s post-commit audit lane. The generic event preserves operation identity and submitted field names; purpose-built events can explain additional business meaning. One-time credentials are marked on their output field so disclosure can be evidenced without retaining the plaintext.

Keep each destination’s contract clear

SurfaceResponsibility
Primary audit resourceOrganization-scoped read/list/search for members and administrators; bounded export for administrators
Customer security collectorSelected events routed to authenticated or verified tenant destinations
Delivery recoveryRetried jobs and retained failures that can be replayed
Object-storage archiveApplication-wide operator copies; originals remain; customer handover requires separately scoped export
Verified ledger providerAdditional proof checks and reconciliation using stable event identities

A tenant’s export is not the complete application history. Events can concern a tenant without granting authority to choose its delivery destination. Application-level viewing follows its own admission path, and the event’s actor need not be a person.

Configure the evidence around the product

Use the shared auditTrail.archiveAfterDays setting to opt into archival copies. Connect customer administration to the organization’s SIEM configuration service, including format, filter and authentication. Declare an optional ledger provider in the emitting runtime’s provider scope and provision its verification material.

For custom business events, trace the complete route from the operation or service caller through event classification to persistence and its configured consumers. A vocabulary entry without an emitter does not produce evidence. Confirm the result in the relevant audit view or receiving system, and monitor the evidence path independently from the business operation’s success.

Follow a submitted change into its evidence

Illustrative write-evidence input to resolveResourceOperationFieldEffect, the shared helper used by the service audit path:

const effect = resolveResourceOperationFieldEffect({
  inputData: { roles: ['reviewer'] },
  preOperationState: { roles: [] },
  postOperationState: { roles: ['reviewer'] },
  isEvidenceField: path => path === 'roles',
});
// effect.changedFields: ['roles']
// effect.fieldEvidence.roles: { before: [], after: ['reviewer'] }

This internal helper illustrates the event payload, not a public application API to call instead of a resource operation. The normal write path decides whether to emit from operation shape and risk, then carries this evidence into the generic event. Submitting the same value again still names the submitted field; only the retained before/after values reveal whether it differed.

Record what matters

Keep a history of the security decisions that matter Guarantee

Wildo turns sign-in activity, access decisions, administrative changes and recorded disclosures into structured evidence. Each event can be searched and connected to the actor, subject and request that explain it.

That history belongs to the product, alongside its business records, rather than existing only as a developer’s log output.

Example: Keep the history that explains security decisions

A reviewer follows a permission change back to its actor and request, then inspects the access activity that followed it.

Sign-in, access and settings events join an attributable audit history.
For engineers

Framework producers call AuditLogsBackendService. The central writer assigns a stable eventId, resolves category and severity, adds available request context and persists the event through the audit repository. The same event identity is used by optional provider sinks.

The selected record construction in _createAuditLog shows the shared envelope:

Selected source from audit-logs.backend.service.ts:

const auditLogData = {
  ...auditEvent,
  eventId,
  ...(correlationId ? { correlationId } : {}),
  ...(frontendServiceName ? { frontendServiceName } : {}),
  ...(clientInstanceId ? { clientInstanceId } : {}),
  eventData: auditEvent.eventData as Record<string, any>,
  eventCategory: classification.category,
  severity: classification.severity,
  createdAt: new Date(),
  updatedAt: new Date()
};

Core events already have typed payloads, classifications and emitting callers. For application behavior, prefer the resource operation’s inherited event when it describes the change; add a purpose-built event when a distinct business fact needs its own payload and meaning.

Keep the subject distinct from the writer

An internal audit write is performed by the framework, but the row retains the person and organization the event concerns. The originating execution context supplies correlation and delivery authority; the temporary repository-write context must not replace the event’s subject.

Choose the additional destinations

The primary audit resource supplies immutable product operations: read and search are present, update and delete are absent. Customer security exports and an optional tamper-evident provider add separate destinations around that primary store.

Post-commit evidence failure does not turn a completed business change into a failed response. Audit failures have their own diagnostics and counters so operators can distinguish an evidence-pipeline problem from the operation that already succeeded. Infrastructure access to the database is a separate control; use the verified sink where independent integrity checks are needed.

Let important changes leave their own evidence Guarantee

High-impact resource changes produce an audit event through the shared operation path. Wildo records the action, its subject and the fields changed, so evidence follows the operation instead of depending on a separate logging call in every handler.

Example: Let important changes leave evidence

An administrator changes a member’s roles. The operation records which member changed, who acted and the marked role values before and after the change.

A high-risk action and its marked role values produce a before-and-after change record.
For engineers

HIGH and CRITICAL write variants meet the automatic audit floor. A lower-risk write can opt in with auditableEvent.auditBelowRiskFloor. Risk is an audit and interaction signal; roles and step-up authentication remain separate declarations.

The actual API-key creation variant combines its administrative gate, risk and one-time response contract:

Selected source from api-keys-organization.shared.resources-config.schemas.ts:

variantType: ResourceOperationVariantType.API_CALL,
isDefault: true,
roles: [CORE_ORG_ROLES.ORG_ADMIN], // Only org admins can create keys
riskLevel: ResourceOperationRiskLevel.HIGH,
// requestDto auto-derived from decorators
customResponseDto: ApiKeyOrganizationSchema.extend({
  plainKey: z.string().min(1).isEphemeral()
}),

This selected variant lives inside the API-key resource’s CREATE operation. When the resource service completes persistence, its post-commit lane calls emitResourceOperationAudit. Custom operations reach the same mechanism through their declared operation path.

Choose which values the trail retains

The shared API-key schema marks authority explicitly:

roles: z.array(RolesSchema).default([]).isAuditEvidence(),

The changedFields list identifies submitted field names, including a value submitted unchanged; it is not a before/after diff. Only marked evidence fields contribute retained values. Choose authority and lifecycle fields deliberately; marking an entire object can retain every nested value it contains.

Single-record changes have a before-image; bulk changes do not promise a per-row prior value. A created record has no prior state. A custom response that reshapes the result still uses the fetched subject as the identity fallback.

Read-shaped operations do not emit this generic performed event, regardless of risk. One-time response disclosure is a separate mechanism for backend-only fields returned at creation and ephemeral response fields. .isAuditEvidence() selects write-evidence values; ordinary business reads need an appropriate explicit audit event when their access must be recorded. A purpose-built business event may coexist with the generic operation event: one explains the business fact, the other preserves the operation, roles and execution context.

Record when deployed access rules change Guarantee

Authentication rules authored in configuration deserve the same visibility as settings changed through a screen. Wildo records the baseline and changes to the deployed authentication posture, including affected user types and before-and-after values. Changes to the application audit-reading policy are recorded too.

Example: Record when authentication rules change

A deployment starts requiring a second factor for a user type. Its policy-change event puts that transition on the same timeline as sign-ins and administrative activity.

A deployed authentication-policy change from optional to required MFA produces a policy record.
For engineers

Keep authentication methods, password policy, MFA, session and step-up settings in the user-type configuration. The authentication-serving startup path calls emitApplicationAuthPolicyChangeIfChanged; ordinary restarts with the same recorded posture do not create a new change.

The decisive portion compares the normalized snapshot with the latest policy event:

Selected source from audit-logs.backend.service.ts:

const currentSnapshot = buildApplicationAuthPolicySnapshot(data.userTypes);
const currentDigest = computeApplicationAuthPolicySnapshotDigest(currentSnapshot);
const prior = await this._readMostRecentApplicationAuthPolicyEvent();

const effectiveAuditReadAccess = data.auditApplicationReadAccess ?? AuditApplicationReadAccess.ROLE_BASED;
const previousAuditReadAccess = prior?.auditApplicationReadAccess?.after ?? null;
if (prior && prior.snapshotDigest === currentDigest && previousAuditReadAccess === effectiveAuditReadAccess) {
  // Unchanged since the last recorded snapshot — the trail already reflects it. Emit nothing.
  return;
}

const before = prior?.after ?? null;
const changedUserTypes = diffChangedApplicationAuthPolicyUserTypes(before, currentSnapshot);

await this.logApplicationAuthPolicyChanged({
  changedUserTypes,
  snapshotDigest: currentDigest,
  before,
  after: currentSnapshot,
  auditApplicationReadAccess: { before: previousAuditReadAccess, after: effectiveAuditReadAccess },
});

This selected implementation segment is inside the startup reconciliation method. A first observation records a baseline with no prior posture. A changed snapshot records before, after and changedUserTypes; the trail itself supplies the previous state. auditApplicationReadAccess separately records the effective audit-reader policy. An older event without that field is treated as an unknown baseline; omitted and explicit role-based configuration are equivalent.

Attribute a deployment honestly

The event is system-attributed because no signed-in person changed it through the product. Connect it to deployment and source history when reviewing who authored and released the change. Background hosts that do not serve authentication do not independently report the same boot policy.

This contract covers the normalized authentication posture, not every setting in the application. A resource-managed security setting follows its own change event; adding a new authored policy dimension should include it in the snapshot and its meaningful comparison.

Record a secret being shown, without recording the secret Guarantee

When the standard resource response reveals a marked one-time credential, Wildo records the disclosure: who received it, which field was shown and which record it belonged to. The evidence contains the fact of disclosure, not a second copy of the credential.

Example: Record a secret being shown without copying it

An administrator creates an API key. The response shows the key once; the trail records that the key was disclosed without storing its plaintext in the event.

A credential is shown once; a separate audit record retains who revealed it and when, without the secret value.
For engineers

API-key creation uses an ephemeral field in its custom response:

Selected source from api-keys-organization.shared.resources-config.schemas.ts:

variantType: ResourceOperationVariantType.API_CALL,
isDefault: true,
roles: [CORE_ORG_ROLES.ORG_ADMIN], // Only org admins can create keys
riskLevel: ResourceOperationRiskLevel.HIGH,
// requestDto auto-derived from decorators
customResponseDto: ApiKeyOrganizationSchema.extend({
  plainKey: z.string().min(1).isEphemeral()
}),

The resource is registered with this response DTO, and the create handler adds plainKey only to that response. The controller’s shared disclosure helper examines the output schema and actual result before recording FIELD_ACCESS.

MarkerDisclosure it describes
.isEphemeral()A marked value returned by an operation, including reissue
.isBackendOnly({ isReturnedAtCreation: true })A stored field explicitly returned at creation

The two markers answer different questions. Backend-only handling takes precedence; an ephemeral response value should not also be declared as a stored backend-only field. Ordinary masked write-only credentials use a different display contract.

Put the marker at the returned field

The helper inspects the top-level output shape and handles returned records individually. Mark a one-time secret where that field is actually returned; do not assume a marker buried inside an arbitrary nested object becomes a disclosure event.

The same disclosure semantics are used by the HTTP and outbound-webhook boundaries. The event records field names, record identity and actor; the secret value is never an event payload. A receiver’s handling of the credential remains separate from proving that the application disclosed it.

Keep the evidence after an account is removed Guarantee

Account deletion should not erase the explanation of what happened. Wildo records completed user and organization deletion using the pre-deletion subject details, and keeps audit records outside the organization’s deletion cascade.

Example: Keep evidence after an account is removed

A scheduled purge removes an organization after its recovery window. The deletion event still identifies the organization and actor even though its business record is gone.

A deletion record preserves relevant evidence after the account is removed.
For engineers

A deletion handler receives utils.currentObject, the subject fetched before persistence. The deletion and configured cascade complete before the postfix emits the event. The snapshot supplies identity and descriptive fields that cannot be reread after removal.

The organization hook preserves the original operation result:

Selected source from organization-deletion-audit.custom-impl.backend.service.ts:

operationPath: {
  resourceIdentifier: CoreResourceType.ORGANIZATIONS,
  operationIdentifier: CoreResourceOperation.DELETE,
  variantType,
  isOperationDefault: variantKey === undefined,
  variantKey,
},
debugLabel: `organizations.delete${variantKey ? `.${variantKey}` : ''}.audit`,
handlers: {
  postfixCoreOperations: async (_id, result, executionContext, _operationPath, utils) => {
    await emitOrganizationDeletedAudit(container, _id, executionContext, utils);
    return result;
  },
},

This selected registration is inside createOrganizationDeletionAuditFactory. The factory is registered for both the internal scheduled-purge variant and the administrator API variant, so the same physical deletion fact is observed through either declared path.

Keep lifecycle facts distinct

A request for deletion marks the lifecycle transition and starts its configured window. A deletion event records the later physical removal. Restoring during the window is therefore not contradictory evidence: request, restore and completed destruction are different facts.

The organization-to-audit relationship excludes audit rows from parent deletion. Access to surviving evidence follows the audit viewing contract; it does not recreate the removed customer account. Apply the application’s evidence-retention and privacy decisions to those surviving records.

Post-commit recording errors are reported separately rather than telling the caller a completed deletion was rolled back. When adding a destructive application operation, preserve the pre-state needed for evidence and register its observation on every declared execution variant.

See repeated attempts as well as successful actions Guarantee

An access refusal can be valuable security evidence. Wildo records authorization denials with the attempted operation, required authority and classified reason, while grouping repeated attempts into an escalating series.

Example: See repeated attempts, not just successful changes

A caller repeatedly requests records outside their scope. The trail preserves the refusal pattern and its growing count without creating a separate event for every guessed identifier.

Refused requests produce audit observations at increasing count milestones, preserving the pattern without a record for every attempt.
For engineers

The authorization service observes refusals at its operation and subject gates. It records the operation identity, variant, required and held roles, attempted identifiers and denial reason. Internal application faults keep their own error handling.

A concealed record still produces useful evidence: the outward not-found response does not disclose whether the record exists, while the authorizer’s internal reason can identify a subject-resolution refusal.

Preserve the pattern without unbounded event volume

The source escalation rule is small because the behavior is precise:

Selected source from authorization-denial-audit.backend.utils.ts:

export function isDenialAuditEscalationMilestone(count: number): boolean {
  if (!Number.isInteger(count) || count < 1) return false;
  for (let rung = 1; rung <= count; rung *= 10) {
    if (rung === count) return true;
  }
  return false;
}

Within the configured window, counts 1, 10, 100 and later powers of ten emit observations. The bucket includes the available user identity, resource, operation, variant and reason; it deliberately excludes the target record ID. Userless calls use a shared fallback bucket.

When the counter is unavailable, the event can still be emitted with an inexact count. The receiver should use the count’s precision flag instead of interpreting every observation as an exact total.

Keep prevention and observation separate

The authorization decision denies access. This volume policy neither rate-limits the caller nor replaces endpoint authorization. Application extensions must reach the normal authorization boundary; an internal service call with deliberately borrowed authority is not an ordinary customer request.

Understand the event

Know who acted, even when no person was there Guarantee

Wildo distinguishes a person, a machine, the application itself and an action whose actor could not be resolved. Automated work stays recognizable in the same trail as human activity.

That distinction helps an investigator separate expected background work from an action that deserves a closer look.

Example: Identify who acted, including automated work

A scheduled cleanup removes an expired invitation. Its actor is the system. An invitation declined through a person’s authorized action can retain that person’s attribution, even when an internal service completes the work.

An event retains attribution to a person, machine or system, with an explicit unattributed case.
For engineers

Resource lifecycle emitters use resolveAuditActorId. They pass the originating execution context instead of inventing a user for background work. Explicit on-behalf-of attribution has priority, then a signed-in user, then a machine or system sentinel.

Selected source from audit-actor.backend.utils.ts:

export function resolveAuditActorId(executionContext: ExecutionContext<any> | undefined): string {
  const onBehalfOfUserId = executionContext?.auditActorUserId;
  if (typeof onBehalfOfUserId === 'string' && onBehalfOfUserId.length > 0) {
    return onBehalfOfUserId;
  }

  const initiatorIds = executionContext?.initiatorIds;

  const userId = initiatorIds?.userId;
  if (typeof userId === 'string' && userId.length > 0) {
    return userId;
  }

  if (initiatorIds?.machineCredential) {
    return AuditActorSentinel.MACHINE;
  }

  return isSystemInitiatedExecution(executionContext?.executionType)
    ? AuditActorSentinel.SYSTEM
    : AuditActorSentinel.UNATTRIBUTED;
}

The resolved value is a user ID or a named AuditActorSentinel. A machine sentinel describes the actor category; the execution context and machine-authentication events identify its credential. Do not query it as though it were a user ID.

Preserve the initiating person across internal work

The internal context’s auditActorUserId carries attribution without granting that person’s permissions to the service. The service still runs under its explicitly authorized execution context. Application extensions should preserve that separation when a user action starts background work.

An UNATTRIBUTED result remains distinguishable from routine system work. Monitoring policy decides how to investigate it; an empty identity should never silently become “the system did it.”

Follow one action across its evidence Guarantee

Wildo carries available request correlation into audit events, logs and errors. Investigators can follow the effects of one action without reconstructing the sequence from timestamps alone.

The originating surface adds context, while browser-installation correlation is an explicit application choice.

Example: Follow one action across its evidence

A request changes a parent record and its children. Their audit events share correlation, allowing the reviewer to inspect that action as one sequence.

A shared request identifier connects event records, with available device context attached.
For engineers

The HTTP boundary establishes request context. _createAuditLog prefers the explicitly supplied execution context’s correlation ID, then the ambient request frame:

const requestFrame = getRequestContextFrame();
const correlationId = requestExecutionContext?.correlationId ?? requestFrame?.correlationId;
const frontendServiceName = requestFrame?.frontendServiceName;

These exact source lines are selected from audit-logs.backend.service.ts. Available fields are added to the record and the export envelope. Internal jobs can carry their own correlation context; a request-only surface field need not exist for background activity.

Use the declared correlationId filter on audit list/search to collect related events. Preserve the execution context through custom service work rather than creating an unrelated context for each child action.

Choose browser correlation deliberately

The shared application configuration accepts this selected fragment:

compliance: {
  clientInstanceIdScope: ClientInstanceIdScope.SESSION,
},

ClientInstanceIdScope comes from the shared model vocabulary. DISABLED collects no installation identifier; SESSION uses session storage; PERSISTENT survives browser restarts and sign-outs. The request-ingestion boundary enforces the configured choice before the field reaches audit consumers.

Choose the mode according to the application’s privacy and investigation policy. Correlation is supporting evidence, not proof that two requests came from the same human, and it never supplies authorization.

Give every security event a consistent meaning Guarantee

Wildo classifies security events with a shared category and severity. A sign-in, a permission change and an access refusal retain their different meanings as they move from the application’s trail into a customer’s security tooling.

Example: Give events a consistent meaning

A successful role grant is still a warning-level administrative event. A security team can review privilege changes without mixing them into routine sign-in traffic.

Successful sign-in is informational, a role change is a warning, and account lockout is critical.
For engineers

AUDIT_EVENT_CLASSIFICATION_MAP covers the core event vocabulary. These two actual entries show why successful activity does not always carry the same severity:

[CoreAuditableEventType.USER_LOGIN]: { category: AuditEventCategory.AUTHENTICATION, severity: AuditEventSeverity.INFO },
[CoreAuditableEventType.USER_APP_ROLES_CHANGED]: { category: AuditEventCategory.AUTHORIZATION, severity: AuditEventSeverity.WARNING },

These are selected entries from security-audit-event-envelope.shared.schema.ts, not a complete map. The central audit writer and exported envelope resolve classification from the event type, keeping the interpretation consistent across destinations.

LevelReader meaning
InfoRoutine activity to retain and correlate
WarningAdministrative or security-relevant change worth reviewing
CriticalA strong security signal requiring attention
Tune review without changing what happened

Customer export configuration can choose categories, a minimum severity and specific event types. A delivery filter selects events; it does not reclassify them. Correlation and event-specific fields let analysts narrow a noisy pattern without discarding the meaning of each event.

When extending core events, provide the classification and typed payload alongside a wired producer. Application-specific event types use the classification resolver’s documented default when they have no core entry; do not assume adding a string alone gives it a particular severity.

Keep customer evidence in the right scope Guarantee

An event’s subject and its delivery destination are different facts. Wildo keeps customer viewing and security-event delivery tied to the appropriate scope, so a customer identifier inside event data cannot choose where that event is sent.

Example: Keep customer evidence in its proper scope

An attempted sign-in names a customer domain. The event can retain that subject without allowing the submitted domain to choose a security-export destination.

Separate customer scopes lead to each customer’s own audit evidence.
For engineers

The central writer preserves the organization an event is about. Its tenant-delivery decision instead takes verified membership destinations or the originating authenticated organization:

const ecOrg = requestExecutionContext?.initiatorIds?.organizationId;
const routeToOrgs: string[] = routingOrgIds ?? (ecOrg ? [ecOrg] : []);

These are exact lines from _createAuditLog in audit-logs.backend.service.ts. The following dispatch iterates that resolved set, reading each destination in its own organization context. An empty set creates no tenant delivery. A disagreement between event data and authenticated scope does not let the data win.

Apply the boundary at each viewing door

The organization audit resource adds the authenticated organization filter. Its bounded export constructs the same scope without a body override. The application audit resource shares persistence but has separate super-administrator access and records cross-tenant reads; it is not a customer shortcut into another organization’s trail.

A targeted event can be visible to the organization it concerns even when it did not originate from an authenticated customer session. Viewing evidence about a subject and sending it to a configured endpoint remain distinct operations.

Keep application evidence available

Events without a tenant delivery route remain part of the primary trail and can reach configured application-level ledger providers. A customer’s security export is therefore the selected tenant stream, not a promise that it contains every event the application records.

Inspect, deliver and preserve

Let customers inspect and export their own history Feature

Customers can read, filter and export their audit records through the application’s resource interface. The same declared contract supplies the standard screens, access rules and API reference.

The audit surface offers inspection, not editing: customer operations cannot rewrite or delete its records.

Example: Let customers inspect their own trail

A customer administrator filters a role change by person and date, then exports the matching window for the security team.

A customer filters a scoped audit ledger and exports the selected records as JSON.
For engineers

The organization route selects the customer scope. READ, LIST and structured SEARCH expose the declared filters; exportAuditLogs returns a bounded synchronous JSON result. The export body cannot choose a different organization.

Its actual request contract is:

Selected source from audit-logs.shared.resources-config.schemas.ts:

export const AuditLogs_ExportRequestDto = z.object({
  eventType: z.string().optional(),
  eventCategory: z.enum(AuditEventCategory).optional(),
  severity: z.enum(AuditEventSeverity).optional(),
  userId: z.string().optional(),
  createdAt: Resources_Filter_DateRangeSchema.optional(),
  // This is a bounded, synchronous API response, not an archive job. Keep its
  // ceiling aligned with the resource LIST operation so callers cannot request
  // a window that the repository will silently truncate.
  // Input stays optional and the backend applies 100 when omitted. Record the
  // default as metadata rather than a Zod output default, which would falsely
  // make the request field required in JSON Schema.
  maxRecords: z.number().int().min(1).max(100).optional().meta({ default: 100 }),
}).strict();

For example, submit { "severity": "warning", "maxRecords": 50 } to the generated collection export operation. The response contains records, format: "json" and totalCount. The default and ceiling are 100 records; larger requests are rejected. This is a selected result window, not an archive of all matching history.

Follow the appropriate viewing authority

Organization access is role-gated and confined to that organization’s records. Application-level audit viewing has its own super-administrator resource and admission requirements; it can include events without a selected organization and observes cross-tenant reads.

The same store supports those distinct doors. Keep scope selection in the authenticated operation, and use the generated reference for the exact deployed route rather than constructing an unscoped query against the collection. Repository-only creation is reserved for framework emitters; customer update and deletion are absent.

Separate viewing from exporting
OperationDeclared role accessScope
READ, LIST and SEARCHOrganization member, organization administrator and superadministratorThe authorized organization context
exportAuditLogsOrganization administrator and superadministratorA bounded export within the authorized organization context

These are the resource’s declared operation roles. They do not authorize choosing an arbitrary tenant or replacing the caller’s scope. Application-wide assurance review uses its separately admitted surface.

Send events to each customer’s security tools Feature

Each customer can configure a destination and choose the security events their team needs. Wildo applies that organization’s delivery settings around a shared event contract, so customers can use their own monitoring and review workflows.

Example: Send customers the events their teams use

One customer receives warning-level authorization events in structured JSON. Another selects a text format and a broader set of categories for a different collector.

Each customer’s event filters and format lead to that customer’s configured delivery destination.
For engineers

Enable the relevant runtime capability and customer entitlement, then connect the application’s authorized administration flow to AuthOrganizationConfigurationService.saveSiemExportConfig. It accepts the administrator’s execution context, AuthScopeType.ORGANIZATION, the organization ID and a configuration patch. The selected payload below uses the actual schema’s fields; its URL and credential are illustrative placeholders.

{
  "enabled": true,
  "webhookUrl": "https://security.customer.example/events",
  "authMethod": "bearer_token",
  "authCredential": "<collector credential>",
  "eventFormat": "json_structured",
  "eventFilter": {
    "categories": ["authorization"],
    "severityMinimum": "warning"
  }
}

The resource’s authCredential is write-only secret material: the standard interface returns a mask and preserves the existing value when that mask is submitted unchanged. To replace the collector credential, submit its new value through that authorized configuration flow and coordinate the change with the receiver.

The configuration service reads the scoped row, then invokes its internal update operation (or creates the row if absent). This is the service integration contract for an application settings screen, not a standalone public update endpoint on the configuration resource.

Understand what reaches the collector

The central audit writer resolves authenticated or explicitly verified tenant destinations. The dispatcher loads that organization’s configuration, checks enabled, applies category/severity/event-type filters and queues the selected envelope. Configuration-change hooks invalidate its cache so subsequent dispatch uses current settings.

Supported authentication choices are bearer token, a named API-key header, HMAC signing and none. The chosen wire format also determines the content type. Delivery attempts and recoverable failures have their own lifecycle.

Confirm setup with a real in-scope security action and a receipt at the customer collector. The tenant stream is distinct from the application’s complete primary history; an event without an authorized tenant destination does not get routed merely because its payload names an organization.

Use the format your security team already receives Feature

Wildo projects the same security event into structured JSON, CEF, LEEF or OCSF. Customers select the format their collector accepts while retaining a common source for the event’s meaning.

Example: Speak the receiving security tool’s format

Two customers receive the same kind of permission-change event in different encodings. Both can identify its actor, action and severity using their chosen tooling.

One event can be encoded as JSON, CEF, LEEF or OCSF for the receiving security system.
For engineers

Set eventFormat on the organization’s SIEM configuration. Its shared vocabulary contains JSON_STRUCTURED, CEF, LEEF and OCSF; the stored values are json_structured, cef, leef and ocsf.

The delivery service selects the formatter from that configuration:

Selected source from siem-webhook-delivery.backend.service.ts:

private _formatBody(envelope: SecurityAuditEventEnvelope, eventFormat: string): string {
  switch (eventFormat) {
    case SIEMEventFormat.CEF:
      return this._formatAsCEF(envelope);
    case SIEMEventFormat.LEEF:
      return this._formatAsLEEF(envelope);
    case SIEMEventFormat.OCSF:
      // OCSF is JSON, so there is no escaping/bounding layer here — the projection returns a
      // document and the only transport concern is serialisation.
      return JSON.stringify(buildOcsfEvent(envelope));
    case SIEMEventFormat.JSON_STRUCTURED:
    default:
      return JSON.stringify(envelope);
  }
}

The excerpt is the actual formatting method. Text formats receive their text content type; JSON-native formats receive JSON. Transport authentication and retries operate around the formatted payload.

Map meaning, not just field names

CEF and LEEF projections account for every envelope field, with declared emitted keys and explicit omissions. Dictionaries supply recognized names and value constraints; the formatter handles escaping and bounded text. OCSF uses declared nested targets and derives class/activity identity from the event.

FormatIntegration consideration
Structured JSONDirect envelope fields
CEF / LEEFFlat text fields, escaping and dictionary limits
OCSFSecurity-event classes and nested structured fields

A format’s representation can differ even when the source fact is the same. Configure a receiver parser for the selected format and verify the resulting fields with an actual event. Adding a new envelope field requires a projection decision; it should not silently disappear into an unmaintained formatter.

Recover security deliveries after an outage Guarantee

Customer security collectors can become unavailable without stopping the application. Wildo queues delivery, retries failures and retains exhausted deliveries for an administrator to inspect and replay.

Example: Recover deliveries when a receiver fails

A customer repairs an expired collector certificate, reviews the retained failures and replays their events through the normal delivery path.

Queued security-event deliveries are retried, with retained failures available for operator recovery.
For engineers

The organization’s SIEM configuration contains delivery.retryAttempts, retryBackoffSeconds and timeoutSeconds. An illustrative selected configuration is:

delivery: {
  retryAttempts: 3,
  retryBackoffSeconds: 30,
  timeoutSeconds: 10,
},

The dispatcher builds and signs the delivery job; the worker checks its integrity before attempting the configured destination. The worker makes an initial attempt plus the configured retries: retryAttempts: 3 permits up to four HTTP attempts. Failed attempts use the configured backoff. The shared circuit-breaker contract pauses a destination after five consecutive failures for five minutes, protecting delivery capacity for other receivers.

Use the retained failure as the recovery unit

The dead-letter record retains the original event envelope, failure reason and attempt information. It does not freeze the formatted HTTP request. The persisted failure counter starts at one and the last-attempt timestamp records that retained failure; these are not a complete HTTP attempt history. The organization’s retrySingle operation accepts an entryId, loads the current scoped delivery configuration and queues the event before deleting the dead-letter entry. Replay therefore uses the current destination, format and authentication settings. retryAll performs the corresponding scoped sweep.

The implementation’s successful path is explicit:

await enqueueEnvelope(dispatcher, scope, siemConfig, envelope);
await deleteRequeuedRows(repo, executionContext, scope, [entryId], utils.logger);

These selected lines are from siem-delivery-dlq.custom-impl.backend.service.ts. A failed enqueue leaves the record available. Cleanup follows successful queueing; a later delivery failure creates its own retained failure record. Receivers should tolerate replay rather than treating the pipeline as exactly-once delivery.

The original business operation does not wait for the collector. Inspect delivery outcomes, not only the business response, when confirming a customer’s export is operating.

Keep a separate copy of your audit history Mechanism

Wildo can copy older audit records into dated JSON artifacts in object storage. Your operational trail remains available, while a separate archive supports longer-term review and controlled handover.

Archiving copies the records. It does not delete the originals.

Example: Keep a separate historical copy

An operator archives events older than three months. A reviewer receives the daily files for a selected period while administrators continue querying the original events through the application.

A dated archive receives a copy of the audit history while the original remains.
For engineers

The shared auditTrail configuration declares archiveAfterDays. This illustrative fragment opts into copying events older than 90 days; keep it in the authored application configuration, not a generated environment file.

auditTrail: {
  archiveAfterDays: 90,
},

Configure a reachable file-storage provider and run the application’s batch scheduling path. The registered audit-logs-archive job checks the current horizon on each execution. With no horizon, it performs no archive work and leaves the primary trail intact.

Recover missed days without a separate checkpoint

The batch writes a deterministic artifact per UTC day. It rewrites recent archivable windows and probes older windows for missing artifacts, so retrying the same period does not create a second archive identity. Successful artifact presence is the progress record.

For a first archival run over existing history, the batch input supplies explicit overrides:

Selected source from audit-logs-archive.batch.backend.service.ts:

export const AuditLogsArchiveBatchInputSchema = z.object({
  /** One-shot archival horizon override (days). Falls back to app config. */
  archiveAfterDaysOverride: z.number().int().min(1).max(2555).optional(),
  /** One-shot look-back width override (days) — for cold-start backfills. */
  lookbackDaysOverride: z.number().int().min(1).max(MAX_LOOKBACK_DAYS).optional(),
  /**
   * One-shot width override (days) for the missing-artifact probe sweep that runs BEYOND the
   * look-back. `0` disables the sweep for this run (pure look-back behaviour).
   */
  backfillProbeDaysOverride: z.number().int().min(0).max(MAX_LOOKBACK_DAYS).optional(),
}).strict();

This is the AuditLogsArchiveBatchInputSchema; use its lookbackDaysOverride when dispatching a wider historical run. Inspect error, windowsArchived, recordsArchived and truncatedWindows in the batch result, then check the corresponding storage artifacts. A complete archive interval needs zero truncated windows and the expected dates; artifact presence alone does not prove that every record was copied. A bounded scheduled look-back is recovery for missed runs, not an unlimited historical scan.

Follow one run from input to artifact

This illustrative operator scenario keeps the configured 90-day horizon and scans just the most recent fully archivable UTC day. It disables the older missing-artifact sweep for this invocation.

The following is a backend invocation fragment, not a public endpoint. batchExecutor is the initialized BatchesCronjobsExecutor_BackendService with the engine archive batch registered. Run it only from trusted operator-controlled backend code: the executor creates the batch’s internal context, and this archive is application-wide.

const result = await batchExecutor.executeCustomBatch('audit-logs-archive', {
  lookbackDaysOverride: 1,
  backfillProbeDaysOverride: 0,
});

Ordinary scheduled execution uses the registered batch and its configured defaults. The direct executor call above returns the batch result; a scheduler’s accepted-publication response does not return or prove that result.

Suppose the invocation begins at noon UTC on September 12, 2026, and the selected day contains two eligible records. These are illustrative result values, not an observed production run:

{
  "tickAt": "2026-09-12T12:00:00.000Z",
  "enabled": true,
  "archiveAfterDays": 90,
  "horizonAt": "2026-06-14T12:00:00.000Z",
  "storageConfigured": true,
  "lookbackDays": 1,
  "backfillProbeDays": 0,
  "windowsBackfilled": 0,
  "windowsScanned": 1,
  "windowsArchived": 1,
  "recordsArchived": 2,
  "truncatedWindows": 0,
  "error": null,
  "durationMs": 25
}

The horizon’s own day is not fully eligible, so this run selects June 13. The logical destination is the application-scoped audit-archive folder with the deterministic filename audit-archive-2026-06-13.json; the configured provider determines the physical storage path. A repeated write uses that same daily identity.

The artifact carries the following metadata. This shortened illustration omits the records array, which contains the two audit records in the actual artifact:

{
  "schemaVersion": 1,
  "kind": "audit-log-archive",
  "applicationId": "example-application",
  "dayWindowUtc": "2026-06-13",
  "windowStart": "2026-06-13T00:00:00.000Z",
  "windowEnd": "2026-06-14T00:00:00.000Z",
  "archivedAt": "2026-09-12T12:00:00.000Z",
  "recordCount": 2,
  "truncated": false
}

Check the application, window, record count and truncation flag against the intended run. An unrelated increase in storage object count is not evidence that this archive was written correctly.

Interpret an outcome before retrying
OutcomeResult signalsMeaning and next step
Disabledenabled: false, error: nullNo horizon was configured or supplied; no archive work runs
Storage absentenabled: true, storageConfigured: false, error: nullArchival was requested but skipped; configure a suitable provider before rerunning
Empty dayA window was scanned but produced no artifactNo records were selected for that day; a missing file can be legitimate
WrittenwindowsArchived and recordsArchived increaseInspect the corresponding artifact and its provenance; counts include rewritten daily artifacts
TruncatedtruncatedWindows > 0, artifact truncated: trueThe daily record cap was reached; that artifact is incomplete even if error is null
Errorerror contains a messageEarlier writes may remain; inspect partial counters and existing artifacts before deciding what to recover

The batch prefers configured Wildo-managed storage, then local-directory storage. A null error alone does not establish a write: disabled and unconfigured runs deliberately return non-error summaries. Failures during scanning or upload return the progress accumulated before the error rather than resetting it to zero.

Bound the historical interval deliberately

Only complete UTC days before the horizon’s day are selected. By default, each run rewrites the newest two eligible days and probes the preceding 90 days for missing artifacts. Existing probe-band artifacts are skipped; empty days intentionally produce no artifact.

The look-back and older probe span are capped together at 2,555 days. Increasing the look-back widens this horizon-relative interval; it is not an arbitrary start-date/end-date export API. A truncated artifact remains incomplete even when a later probe sees that its file exists. Review both coverage and truncation before treating an interval as complete.

Keep archive and retention decisions separate

The audit resource exposes no update or delete operation. Storage lifecycle, access to archived artifacts and the intended retention period remain operator decisions. This copy mechanism does not shrink the database or promise a backup of the rest of the application.

Choose the right audience for the artifact

The archive batch collects application-wide rows and explicitly bypasses contextual organization filtering. Its output is an operator artifact, not a customer-scoped download. For customer handover, use the bounded organization export and inspect its truncation result; do not hand over a raw application archive. Archival copies do not prune the primary rows.

Check evidence against an independent ledger Mechanism

An optional verified ledger adds an integrity check beside the application’s audit store. Wildo can send events to immudb, retain a verification anchor and reconcile missed writes using the same event identities.

Example: Check evidence against an independent ledger

An operator investigates a disputed record using its matching event in the ledger. Verification checks the ledger history against its recorded anchor instead of relying only on application permissions.

An event in the primary record is checked against the corresponding event in an independent ledger.
For engineers

Wonder Todos enables EngineCapability.COMPLIANCE_AUDIT_TRAIL and declares this provider in its backend provider scope:

Selected source from wildo.saas.config.ts:

immudb: {
  engineCapabilities: [EngineCapability.COMPLIANCE_AUDIT_TRAIL],
  providerCapabilities: ['COMPLIANCE_AUDIT_TRAIL'],
  protocols: [],
},

This is a selected provider entry from wildo.saas.config.ts. Provision the immudb backing service and signing material, synchronize provider/runtime configuration and start the responsible backend runtime. The composite sink resolves the declared provider; capability enablement alone is not a connection.

Preserve a stable verification basis

The verified client checks cryptographic proofs and maintains a durable anchor. Retain that anchor across restarts so verification can compare history over time. A proven integrity failure, an unusable verification configuration and degraded assurance have different diagnostics and should drive different operational responses.

Provider failure is isolated from the primary audit write and from other sinks. Monitor the destination’s state as well as the application’s health.

Reconcile using the primary trail

The scheduled reconciler replays events by eventId. Its cursor combines creation time with the event identity, so several events sharing a timestamp are not skipped. Repeated writes are idempotent at the sink.

This gives the operator a recovery path for missed inline deliveries while the authoritative records remain available. It is not a blanket guarantee that every direct database alteration is automatically discovered: use the ledger’s proofs, reconciliation outcomes and retained anchors together when checking a particular event or interval.

Provision the verifier separately from the server
SettingProvision on the emitting runtimeWhat it establishes
IMMUDB_STATE_DIRAn absolute, access-protected path backed by durable storage; mount that same storage after restartContinuity with the last verified root; an ephemeral directory loses cross-restart rollback detection
IMMUDB_STATE_PUBLIC_KEYThe trusted server signing public key, SPKI PEM, ECDSA P-256State-signature verification; the server must use its matching private signing key via --signingKey
IMMUDB_EXPECTED_SERVER_UUIDThe expected stable server UUID, where knownDetects accidental replacement; a server-asserted UUID does not replace the cryptographic public-key pin

These provider secret-contract entries feed stateDir, statePublicKeyPem and expectedServerUuid in the verified client. Supply them through the provider configuration channel; never put the server’s private signing key into the verifier. A local example path for the state directory is /application/.wildo-saas/local_data/immudb-verifier; a container must mount durable storage there.

Once the sink is configured, an application-side verification caller can inspect an individual event. Usage excerpt with the already-resolved sink and event identity:

const verification = await sink.verifyAuditEvent(eventId);
const { verified, status, detail } = verification;

The result distinguishes Verified, Absent, Tampered and Unverifiable. Inspect the status and diagnostic together: successful inclusion of one event does not prove that every expected event was delivered. Check durable-state and signature-pinning diagnostics as well; a successful read with those protections absent establishes less than a pinned, continuous history.

Make a person’s data journey accountable

Understand what personal information your application holds, answer requests for it and carry erasure through records, attachments and connected services.

Wildo connects those actions to declared data relationships and treatments. Your application gives people a clear request process; the operator decides the purpose, authority and retention commitments behind it.

A person’s information connects to its inventory, export and declared keep-or-remove treatment.

Decisions that follow the data

Describe what you hold

Classify personal fields and distinguish supplied information from observed activity and derived conclusions. Make disclosure decisions explicit in the model.

Give the right answer

Assemble access and portability bundles with different selection rules. Keep the report of inclusions and exclusions beside the request.

Follow through on erasure

Delete or scrub according to declared rules, address sessions and attachments, and inspect connected-service outcomes before closing the request.

Example: Take a request from intake to outcome

A person asks to leave. The application verifies the request and offers the appropriate export. Erasure then processes the declared records, removes personal attachment references and invokes relevant external effects. Required retained records stay outside ordinary use; the operator reviews the receipt and follows up on any remaining action.

For engineers

Personal-data handling has several independent declarations. Combining them into a single “privacy enabled” switch would hide choices the application needs to make.

DecisionDeclaration or entry pointResult
What kind of data a field holds.dataCategories(...)Feeds the declared personal-data inventory
Where a value came from.portabilityProvenance(...) and resource defaultControls portability field selection
Whether a resource participates in exportsystemAccessPolicy.exportSubjectExplicit disclosure decision
What a retained row keeps or scrubsretentionPolicy and .impersonalizeWith(...)Preserves, hides and freezes the row; applies treatments
Which account belongs to the subjectdataSubjectPolicyIdentifies sessions to revoke after the scrub commits
When a temporary row expires.ttl(...) plus the relevant expiry mechanismRemoves due rows through its schedule

Keep request authority in the application

SystemAccessService supplies exportSubjectAsSystem and eraseSubjectAsSystem. The application supplies verified subject IDs, the request workflow and protected delivery. These are backend system operations, not ready-made public routes. A contact without an account can still be a data subject.

For export, choose the access or portability purpose and inspect the returned report. Undeclared participating resources refuse the export; explicit exclusions are reported. Backend-only and secret fields remain withheld. A retained hidden row is not automatically revealed by an export.

Connect the model to the outcome

This actual user-resource configuration declares two separate facts: its row is retained and scrubbed, and its ID identifies the sign-in principal. The field treatments and relationship registry are defined elsewhere in the same resource system.

  retentionPolicy: {
    mode: ErasureRetentionMode.RETAIN_AND_IMPERSONALIZE,
  },
  dataSubjectPolicy: { kind: ResourceDataSubjectKind.SELF_PRINCIPAL },

Source: users.shared.resources-config.schemas.ts, selected declarations, with intervening comments omitted.

Call erasure with explicit IDs. The orchestrator classifies relationships before destructive work, handles dependents and then scrubs the subject. A retained row is preserved, not physically removed; impersonalization means applying its declared treatments, not promising irreversible anonymity. Records intentionally kept for ordinary accounting can require a different relationship treatment from keep/hide/freeze retention.

Review the whole receipt

Check the subject result, destructive edge execution and external-effect outcomes. Local commit and third-party completion are separate events. Session revocation is scheduled after commit and its errors require operational attention. A file’s unavailable state and physical storage cleanup are also different stages.

A provider failure must not be hidden by a successful database write. Use the framework’s incomplete-external-effect selector and route unresolved outcomes to the operator. For retained-record investigations, the dedicated read requires trusted human attribution and a reason, and withholds the result if the disclosure cannot be audited.

The operator decides retention purposes, periods, backup treatment and how requests are verified and fulfilled. Wildo supplies mechanisms and evidence for those decisions; the application’s actual behavior and operating process determine what it can promise.

Carry declaration through to the request result

Relationship selection precedes resource and field filtering. Owned dependent rows participate in both export purposes; non-owned rows referring to the subject participate in access selection but not portability. Disabled or unrelated edges are excluded. This is a subject-neighbor selection, not an unrestricted recursive graph export.

Usage excerpt from an application request workflow with already verified IDs and scope; each request is authorized separately:

const bundle = await systemAccessService.exportSubjectAsSystem(
  subjectResourceType,
  verifiedSubjectIds,
  SubjectExportPurpose.PORTABILITY_ART_20,
  { scopeContext },
);

const selectionReport = bundle.report;
// Review the selection before delivering personal data through the protected request channel.
const selectedData = bundle.slices;

For erasure, use the separate eraseSubjectAsSystem entry point and inspect its receipt. Local scrub, dependent execution, session resolution and external-effect completion answer different questions. Preserve unresolved outcomes for follow-up rather than replacing them with a single successful request flag.

Know the information and the choice

Know what personal information your application holds Mechanism

Contact details, identity information and free text need more precise descriptions than “sensitive data.” Wildo attaches categories to the fields that hold them and makes those declarations available to the application’s information inventory.

The classification sits beside the field’s other rules, so understanding the data is part of defining it.

Example: Describe a contact accurately

A contact’s name is identity information; the company they work for is professional information. Classifying each field preserves that distinction when the application explains its data practices.

Identity and professional information are classified separately in the personal-data inventory.
For engineers

This actual Wonder CRM contact schema shows category and erasure declarations on two fields. They answer different questions: what the value contains and what an erasure does to it. The source comment explains why the retained contact keeps a replacement name:

Source: contact.schemas.ts (selected excerpt).

  companyId: z.string().min(1).optional().isDBIndexed().isForeignKey().isSummaryField()
    .impersonalizeWith(RedactionType.REMOVE)
    .dataCategories(PersonalDataCategory.PROFESSIONAL),
  /*
   * A contact's NAME is the personal data this resource exists to hold, and it is scrubbed on
   * erasure rather than removed: the row is retained (see `retentionPolicy` on the config), and a
   * retained row whose name is null reads as corrupt where a masked one reads as erased.
   */
  name: z.string().min(1).max(120).isSummaryField()
    .impersonalizeWith(RedactionType.MASK, { maskValue: '[erased]' })
    .dataCategories(PersonalDataCategory.IDENTITY),

PersonalDataCategory is the named vocabulary for identity, contact details, credentials, financial data, usage and the other supported categories. Declare the specific category rather than a general sensitivity level. The factory validates declared members. There is no resource-wide category default because one row can contain several kinds of information.

The inventory derives from declarations, not identifiers such as email. Credential decorators provide their own category signal. Introspection reports fields that already have a privacy declaration but lack a category, and distinguishes those omissions from resources whose personal-data question is unanswered.

These declarations feed the compliance facts consumed by application documentation work. They do not discover personal data hidden in arbitrary free text or verify the lawfulness of collecting it. Review actual fields and purposes, then keep that decision close to the schema as the application evolves.

Answer a person's request

Answer a person’s data request across your application Mechanism

A person’s information can span an account, a profile, memberships and records created about them. Wildo follows the declared relationships to assemble a data bundle instead of leaving the application to remember each table.

Access and portability are separate requests. The chosen purpose controls the selection, and a report explains what was included or withheld.

Example: Respond to an account holder

An account holder requests their information. The application verifies the request, asks Wildo for an access bundle and delivers it through a protected channel. A portability request uses the narrower purpose against the same resource model.

A subject request gathers participating account, profile and membership information into an export with a receipt.
For engineers

The backend SystemAccessService.exportSubjectAsSystem takes explicit subject IDs and a SubjectExportPurpose. Use it from the application’s verified request workflow; receiving, authorizing and delivering the request remain application responsibilities.

Every participating resource needs an explicit systemAccessPolicy.exportSubject decision. The subject also needs system READ. The following actual Wonder Todos declaration makes draft notes exportable and gives their fields a default provenance; surrounding operation declarations are omitted:

Source: draft-notes.resources-config.ts (selected excerpt).

    resourceIdentifier: TasksManager_ResourceType.DRAFT_NOTES,
    resourceFieldIdentifier: TasksManager_ResourceFieldIdentifier[TasksManager_ResourceType.DRAFT_NOTES],
    resourceRelationships: resourcesRelationships,
    isSystemResource: false,
    isAnonymizable: true,
    transpositionPolicy: ResourceTranspositionPolicy.ADD,
    // assurance-control: WILDO.PRIVACY.SUBJECT_DATA_EXPORT — authenticated draft notes are subject-owned data and must be available to the fail-closed export sweep.
    systemAccessPolicy: { exportSubject: ResourceSystemAccessMode.ALLOWED },
    portabilityPolicy: { defaultProvenance: DataPortabilityProvenance.SUBJECT_PROVIDED },

Call exportSubjectAsSystem(resourceType, subjectIds, purpose, options) with ACCESS_ART_15 or PORTABILITY_ART_20. Supply the appropriate scopeContext where the resource is scoped. The result separates slices, which contain personal data, from report, which describes the selection. Keep the values out of ordinary logs.

Before collecting rows, the orchestrator checks participating-resource declarations and, for portability, field provenance. An absent export decision refuses collection rather than silently omitting that resource. An explicit FORBIDDEN child is excluded with an explanation; forbidding the subject itself refuses the bundle. Backend-only and write-only fields are withheld and named in the report.

The internal scan supports resources without a public LIST operation while still applying retention hiding. Export is not the retained-data review door: an already-erased hidden row stays hidden. Review the report before delivery and implement any controller-reviewed disclosure outside this automated selection deliberately.

Select relationships before selecting fields

The orchestrator examines the subject’s declared neighboring relationships, using the original subject IDs. This is not an unrestricted recursive walk through everything reachable from a record.

Relationship to the subjectAccess selectionPortability selection
Dependent rows owned by the subjectIncluded before resource/field policy checksIncluded before provenance filtering
Non-owned rows whose foreign key points to the subjectIncluded as records concerning the subjectExcluded as non-subject-provided rows
Non-owned rows that do not point to the subjectExcludedExcluded
Disabled relationshipExcludedExcluded

For example, a dependent note and an independently owned record referring to its author have different selection contracts even if both contain the same user ID. Relationship semantics decide the edge; resource permission and field provenance then decide what may be disclosed.

The actual Wonder Todos user-to-draft-note declaration demonstrates the owned case. Selected source from tasks-manager.relationships.ts, with comments omitted:

createResourcesRelationship(
  CoreResourceType.USERS, TasksManager_ResourceType.DRAFT_NOTES,
  ResourceRelationshipCardinality.ONE, ResourceRelationshipCardinality.MANY,
  {
    nature: RelationshipNature.COMPOSITION,
    isPrimaryScope: true,
    foreignKeyField: 'userId',
    parentResourceRequirement: ResourceParentResourceRequirement.OPTIONAL,
    accessScopeStrategy: ResourceRelationshipAccessScopeStrategy.REQUIRES_CONTEXT,
    childOperations: { lifecycle: { onParentDelete: { enabled: true, mode: ChildLifecycleMode.IMMEDIATE } } },
    contextPolicy: {},
  }
),

COMPOSITION resolves to dependent ownership. Export includes that edge before its resource/field policies; the parent-delete declaration separately governs erasure and does not determine portability field provenance.

Usage excerpt inside the application’s verified request handler, with its resolved service, subject type, IDs and scope:

const bundle = await systemAccessService.exportSubjectAsSystem(
  subjectResourceType,
  verifiedSubjectIds,
  SubjectExportPurpose.ACCESS_ART_15,
  { scopeContext },
);

const selectionReport = bundle.report;
const personalDataSlices = bundle.slices;

Inspect the report’s exclusions before protected delivery of the slices. An empty slice alone does not explain whether there were no matching rows, an excluded relationship or a withheld field. Do not write personal-data slices to application logs.

Explain where each exported value came from Mechanism

A profile can mix information someone entered with activity the service observed and conclusions the application calculated. Those values do not belong in every kind of export.

Wildo lets you describe their origin in the model, so portability selection follows an explicit rule rather than a guess about field names.

Example: Separate an answer from a score

A person supplies a preference and the application calculates a score. The preference can travel in their portable bundle; the derived score is treated differently, even when both live on the same record.

Provided and observed information enters a portability bundle while derived information stays outside it.
For engineers

Use portabilityPolicy.defaultProvenance on a resource and .portabilityProvenance(...) on fields that differ. This is independent of personal-data categories and erasure treatments. The actual draft-note configuration supplies a resource-wide default:

Source: draft-notes.resources-config.ts (selected excerpt).

    resourceIdentifier: TasksManager_ResourceType.DRAFT_NOTES,
    resourceFieldIdentifier: TasksManager_ResourceFieldIdentifier[TasksManager_ResourceType.DRAFT_NOTES],
    resourceRelationships: resourcesRelationships,
    isSystemResource: false,
    isAnonymizable: true,
    transpositionPolicy: ResourceTranspositionPolicy.ADD,
    // assurance-control: WILDO.PRIVACY.SUBJECT_DATA_EXPORT — authenticated draft notes are subject-owned data and must be available to the fail-closed export sweep.
    systemAccessPolicy: { exportSubject: ResourceSystemAccessMode.ALLOWED },
    portabilityPolicy: { defaultProvenance: DataPortabilityProvenance.SUBJECT_PROVIDED },

SUBJECT_PROVIDED covers input the person supplied. OBSERVED covers information generated by their use of the service. DERIVED covers the application’s conclusions, scores and assigned classifications. Do not classify all non-typed values as derived: observed usage has its own meaning.

The field declaration overrides the resource default. In the portability selection, provided and observed values are eligible while derived values are excluded and reported. Access selection is broader, subject to the separate non-disclosable-field rules.

An unresolved provenance refuses portability collection instead of silently dropping a field. Choose a default only when it accurately describes the resource, then inspect exceptions such as computed status fields. Your application still decides which request applies and reviews delivery; the declaration is a selection contract, not a determination of legal entitlement.

Override only the fields with a different origin

Illustrative field declaration using the existing decorator; the surrounding resource uses the SUBJECT_PROVIDED default above:

const preferenceFields = z.object({
  preference: z.string(),
  calculatedScore: z.number()
    .portabilityProvenance(DataPortabilityProvenance.DERIVED),
});

The preference inherits the resource default. The calculated score overrides it and is excluded from portability selection, with that selection reported. Access selection remains distinct. This field decision applies only after the relationship has admitted the row; it does not pull unrelated records into the bundle.

Carry erasure through the system

Remove personal details while preserving required records Mechanism

Erasing a person’s information does not always mean deleting every related record. Some records must remain, while names, contact details or other personal values must be removed from them.

Wildo separates those decisions. Relationships determine what happens to dependent records; retention and field treatments determine what survives and what is scrubbed.

Example: Close a contact’s data journey

A contact’s name is replaced and optional personal details are cleared. Related records follow their declared lifecycle treatment. The retained contact row remains for its stated purpose, outside ordinary reads and edits.

Personal field values are scrubbed while the retained record remains stored under its declared treatment.
For engineers

RETAIN_AND_IMPERSONALIZE keeps, hides and freezes a row while scrubbing declared fields. RETAIN_ONLY is for rows with no personal field values to scrub. The actual USERS configuration combines the retained-row decision with the separate session-principal decision:

Source: users.shared.resources-config.schemas.ts (selected declarations; intervening comments omitted).

  retentionPolicy: {
    mode: ErasureRetentionMode.RETAIN_AND_IMPERSONALIZE,
  },
  dataSubjectPolicy: { kind: ResourceDataSubjectKind.SELF_PRINCIPAL },

Fields declare .impersonalizeWith(...): REMOVE clears a clearable field, MASK writes a schema-valid replacement, and RESET restores a supported static default. The factory rejects contradictions such as treatments on RETAIN_ONLY or a scrubbing mode without any effective scrub. A retained row is not an anonymous row, nor proof that re-identification is impossible.

Invoke SystemAccessService.eraseSubjectAsSystem(resourceType, explicitIds, options) through your application’s request workflow. It classifies dependent edges and refuses unclassified destructive decisions before writing. Dependents are handled before the subject, making a retry possible when work cannot finish. IMPERSONALIZE_AND_RETAIN on a relationship is an edge decision; immediate versus lazy execution is a separate timing choice.

Inspect subjectImpersonalized, each edge’s disposition and executed, and external effect outcomes before recording completion. An executed empty edge means there was nothing to remove; a declined destructive edge requires follow-up. Administrative continuity acknowledgements are explicit per-request controller decisions, not defaults to switch on.

Adding retention changes the persisted schema and uniqueness treatment. Register the resource coherently and apply the corresponding database migration. The operator decides why a record is retained and for how long; erasure retention does not itself set an expiry schedule.

Give each personal field a concrete treatment

Illustrative schema fragment using the existing field decorators, paired with RETAIN_AND_IMPERSONALIZE:

const contactFields = z.object({
  name: z.string().impersonalizeWith(RedactionType.MASK, { maskValue: 'Erased contact' }),
  personalNote: z.string().nullish().impersonalizeWith(RedactionType.REMOVE),
});

The replacement must satisfy the field schema; REMOVE writes null, so the receiving field must accept null. These treatments scrub the personal values while the retention policy keeps the row out of ordinary use. The contact’s separate data-subject policy must also state whether it represents an account.

Usage excerpt after the application has verified the request and resolved its service and scope:

const receipt = await systemAccessService.eraseSubjectAsSystem(
  subjectResourceType,
  verifiedSubjectIds,
  { scopeContext },
);

const unresolvedExternalEffects = receipt.externalEffects.filter(effect =>
  isIncompleteSubjectErasureExternalEffect(effect.status),
);

The selector comes from @wildo-ai/saas-models. It includes partial, failed, unregistered and acknowledged-non-erasure outcomes. Controller acknowledgement does not make retained third-party data disappear. Inspect these outcomes alongside subjectImpersonalized, edge execution and session resolution before closing the request; do not turn successful local return into an unconditional completion status.

End the erased person’s active sessions Guarantee

Erasing account information must also address the person’s continuing access. Wildo connects the scrubbed record to its declared sign-in principal and schedules session revocation after the local change commits.

A contact without a login and an account holder are different cases. The declaration makes that distinction explicit.

Example: Erase an account, not its author

Erasing a user ends that user’s sessions. Erasing a business record that mentions an employee as its author must not sign that employee out.

Erasure of a sign-in principal leads to session closure; a contact without a login is a different case.
For engineers

dataSubjectPolicy answers whose sessions should end. It does not answer whether the record contains personal data. The actual user configuration names the row itself as the principal:

Source: users.shared.resources-config.schemas.ts (selected declarations; intervening comments omitted).

  retentionPolicy: {
    mode: ErasureRetentionMode.RETAIN_AND_IMPERSONALIZE,
  },
  dataSubjectPolicy: { kind: ResourceDataSubjectKind.SELF_PRINCIPAL },

Use SELF_PRINCIPAL when the row ID is the user ID; LINKED_PRINCIPAL when a declared sessionPrincipalField identifies the account; and NO_PRINCIPAL when no login belongs to this subject. A contact or lead can be a person while correctly having NO_PRINCIPAL. A creator or owner foreign key alone is not a session declaration.

The common scrub path resolves principals before values are changed and schedules revocation after commit. This covers orchestrated erasure, direct impersonalization and an impersonalizing parent cascade. Startup supplies the token issuer’s invalidation function through SubjectSessionRevocationBackendService; custom bootstrap code must preserve that registration.

Revocation is post-commit work. A failure cannot restore already-scrubbed data and is logged as an access problem requiring operational follow-up. Treat a local erasure receipt and confirmed session termination as related outcomes, not interchangeable evidence. Monitor the revocation errors and verify an old session is refused in the application’s end-to-end erasure checks.

Choose an account linkage explicitly

Alternative resource-policy fragments, not three simultaneous declarations:

// A subject row that represents a separate account:
dataSubjectPolicy: {
  kind: ResourceDataSubjectKind.LINKED_PRINCIPAL,
  sessionPrincipalField: 'accountId',
},
// A contact with no sign-in account instead uses:
// dataSubjectPolicy: { kind: ResourceDataSubjectKind.NO_PRINCIPAL },

For the linked variant, accountId must be the declared field identifying the USERS row, with the resource configuration satisfying its linkage checks. sessionPrincipalField is forbidden for SELF_PRINCIPAL and NO_PRINCIPAL. Never substitute the creator’s ID just because it is available.

The current erasure report includes sessionRevocation.principalUserIds and lookupFailed. An empty principal list with a successful lookup can legitimately mean no account; a failed lookup is a different outcome requiring attention. Keep that receipt alongside operational revocation errors and an end-to-end check that an old session is refused.

Erase personal attachments and their identifying metadata Mechanism

A personal file can survive even after its database field is cleared. Wildo follows scrubbed file fields into the attachment lifecycle, withdrawing the file and removing identifying metadata such as its original filename.

Files deliberately retained by the application follow a different treatment, so keeping a required document is an explicit choice.

Example: Remove an identity attachment

A file field marked for removal points to an identity document. Erasure clears the reference, marks the file unavailable and replaces the identifying filename before storage cleanup removes the bytes.

A file's declared erasure policy closes access while a separately retained file remains.
For engineers

For a resource configured with ErasureRetentionMode.RETAIN_AND_IMPERSONALIZE, field declarations decide which attachments the scrub removes. This illustrative schema fragment keeps a required business document while removing a personal identity attachment:

import { z } from 'zod';
import { z_file } from '@wildo-ai/zod-decorators';
import { RedactionType } from '@wildo-ai/saas-models';

const attachmentFields = z.object({
  idDocument: z_file({ allowedMimeTypes: ['application/pdf'] })
    .nullish().impersonalizeWith(RedactionType.REMOVE),

  invoicePdf: z_file({ allowedMimeTypes: ['application/pdf'] })
    .nullish().impersonalizeWith(RedactionType.KEEP),
});

The fields belong in the resource’s schema; the resource’s retention policy and subject-erasure wiring select when this treatment runs. The fragment does not start erasure by itself. REMOVE writes null, so the personal attachment field must accept a cleared value.

For these declarations, buildImpersonalizeScrubPatch(attachmentFields) produces { idDocument: null }. The handler receives that field name and follows the existing file links before their attachment lifecycle is completed.

FieldRecord after the scrubAttached file
idDocument · REMOVEReference cleared to null.Marked DELETED; filenames overwritten and uploader attribution removed.
invoicePdf · KEEPReference unchanged.Not erased by this treatment. Its existing file access rules still apply.
File field without a treatmentReference unchanged.Not erased by this treatment either; omission is not a privacy classification.

Keeping the invoice is an application decision about that document’s contents and retention purpose. KEEP does not redact personal information inside a PDF. Nor does hiding the retained parent automatically block every file route: resource-scoped serving checks the parent, while global file serving checks the file’s own access scope.

Withdraw the file and scrub its own identifying values

processFileFieldsForImpersonalize invokes the privacy-specific eraseFile path for the scrubbed fields. This actual repository update shows why ordinary lifecycle deletion is not equivalent:

Source: files.backend.service.ts (selected excerpt).

    const erasedFile = await this.filesRepository.update<File>(
      internalContext,
      { _id: fileId },
      {
        status: FileStatus.DELETED,
        deletedAt: new Date(),
        updatedAt: new Date(),
        filename: ERASED_FILE_NAME_PLACEHOLDER,
        originalFilename: ERASED_FILE_NAME_PLACEHOLDER,
        uploadedBy: undefined,
        uploadedByEntityType: undefined,
      } as Partial<File>,
      options
    );

eraseFile marks DELETED and removes the file row’s filename/uploader attribution. Serving checks the downloadable status set; storage cleanup handles physical bytes through the normal cleanup lifecycle. Do not describe the soft-delete write as immediate destruction of every stored copy.

An untreated or KEEP field is not erased by this scrub path. That allows an intentionally preserved artifact to remain usable; it also means the application must classify personal file fields carefully. When at least one field is being scrubbed, a linked file without a usable linkedFieldName is also erased and a warning is logged. With no scrubbed fields, this handler returns without querying files.

Owner context is derived from the authoritative resource/file during internal cascade work, while normal requests retain their own authorization checks. Direct file erasure also removes associated extracted RAG content through the file-binding removal seam. Physical cleanup follows the configured soft-delete retention period (30 days by default). For remote references such as Google Drive, this does not promise deletion of the external original. Storage-provider deletion and backup retention remain separate from the file-row scrub.

Carry erasure into connected services Mechanism

Personal information can continue living at a connected service after the application removes its own copy. Wildo gives integrations an erasure hook and collects their outcomes alongside the local result.

The report preserves the distinction between completed work and a service that still needs attention.

Example: Follow a request beyond the database

A person’s local records are processed, then the billing integration handles its customer data. If that provider cannot complete a step, the application receives a specific outcome to follow up instead of treating local success as the whole request.

Local erasure leads to external-service effects with separate completed and action-needed outcomes.
For engineers

An external effect declares a stable effectRef, an appliesTo(subjectResourceType) predicate and an async run(context) returning an outcome. Register it with SubjectErasureExternalEffectsRegistryBackendService during backend setup. Billing demonstrates the contract: the effect declares exactly which subject resources it owns before its run method does any provider work.

Source: billing-subject-erasure-effect.backend.ts, effect opening; the remaining provider work is omitted.

export function createBillingSubjectErasureEffect(
  dependencies: BillingSubjectErasureDependencies,
): SubjectErasureExternalEffect {
  return {
    effectRef: BILLING_SUBJECT_ERASURE_EFFECT_REF,

    // USERS and USER_SELF share the same user subject; organisation teardown is separate.
    appliesTo(subjectResourceType: ResourceType): boolean {
      return subjectResourceType === CoreResourceType.USERS
        || subjectResourceType === CoreResourceType.USER_SELF;
    },

The shortened comment above summarizes the source comment. Backend startup registers this effect through SAAS_SERVICE_TYPES.SubjectErasureExternalEffectsService when billing is configured. A custom integration supplies its own stable reference, subject predicate and implementation. Re-registering the same reference replaces it rather than appending another effect. The run context contains subject IDs and scope, not the personal fields already scrubbed; resolve provider identifiers from the integration’s own records.

Effects execute sequentially after the local commit. They cannot roll back a remote API call or the completed local erasure. Return step-level outcomes that let the operator distinguish completed work, nothing to erase, partial work and failure. The runner retries clean FAILED outcomes or thrown failures within a bound; it does not automatically replay a reported PARTIAL. Return provider failures as structured outcomes with per-step detail. Throwing loses that detail and can trigger a retry, so external actions must tolerate replay; do not rely on an exception to roll back work already accepted by a provider.

Inspect report.externalEffects with selectIncompleteExternalEffects rather than inventing a status comparison. Persist the outcome needed for follow-up without copying erased personal values into operational logs.

HTTP_API-backed resources declare their own erasure stance and subject link. A declared external erasure operation removes the matching remote rows through the adapter. An acknowledged non-erasure is a per-request controller decision recorded as incomplete, not proof of removal. Integrations that store personal information must register their own responsibility; the registry cannot discover every third-party copy.

Remove billing identity without breaking retained invoices Feature

A billing customer can carry personal details as well as links to financial records the operator needs to preserve. Wildo’s billing erasure effect handles the personal side without deleting the customer record that retained invoices depend on.

The resulting receipt identifies the provider steps that completed and any that need follow-up.

Example: Close a subscriber’s personal billing data

The application erases an individual account. Its billing effect cancels relevant subscriptions, scrubs the provider customer and detaches payment methods, while leaving retained invoices linked to the preserved customer record.

Billing-subject treatment stops the subscription while preserving the separately retained invoice history.
For engineers

Backend startup registers the billing effect when the relevant billing services are available. It resolves billing accounts for the subject and delegates through the configured billing provider. This actual effect fragment shows the ordered actions and the complete-result boundary:

Source: billing-subject-erasure-effect.backend.ts (selected excerpt).

        await cancelSubscriptions(provider, dependencies, variants, account._id, steps);
        await anonymizeCustomer(provider, providerCustomerId, steps);
        await detachPaymentMethods(provider, providerCustomerId, steps);
      }

      const failed = steps.filter((step) => !step.succeeded);
      if (failed.length === 0) {
        return {
          effectRef: BILLING_SUBJECT_ERASURE_EFFECT_REF,
          status: SubjectErasureExternalEffectStatus.COMPLETED,
          detail:
            `Provider-side erasure completed for ${accounts.length} billing account(s): subscriptions cancelled, `
            + `customer anonymized, payment methods detached. The customer record was KEPT (anonymized) so the `
            + `retained invoices remain attributable.`,
          steps,
        };
      }

The effect never calls deleteCustomer. Retaining the provider customer keeps invoice attribution intact; the provider implementation scrubs the supported personal customer fields instead. An absent billing account produces NOTHING_TO_ERASE. An unsynced local account contributes a successful skip-unsynced-billing-account step; the overall result still reflects the other accounts and steps.

The effect applies to USERS and USER_SELF and checks that user billing is registered before reading accounts. It selects the user-scoped billing variant; an employee account is not a reason to cancel an organisation’s shared subscription. Application builders should verify their billing subject ownership before extending the effect.

Each action records its own outcome. A failed provider step produces a partial receipt after local erasure, with details for provider-side follow-up. Verify provider state independently when completing the operational request. Financial retention purposes and periods are operator decisions; this mechanism preserves the relationship needed to implement them.

Control what remains

Keep retained records out of everyday work Guarantee

A retained record should not quietly reappear in search results or continue contributing to an everyday total. Wildo carries the retained-state boundary through its ordinary data paths, keeping those records hidden and immutable.

Exceptional review uses a separate accountable read, rather than weakening the normal rules.

Example: Keep a dashboard consistent with erasure

A record is retained after its personal fields are scrubbed. It no longer appears in the normal list or contributes through the framework’s retained-row filtering to a chart. The row still exists for its declared retention purpose.

Retained hidden records are excluded from ordinary lists, search results and charts.
For engineers

Opting a resource into retentionPolicy gives it a retention marker. Repository filtering and aggregation consume one predicate authority. This actual operation set limits which read operations may suppress hiding; carrying the flag into a write does not unlock the row:

Source: retention-hide-predicate.backend.ts (selected excerpt).

export const RETENTION_SEE_THROUGH_SUPPRESSIBLE_CORE_OPERATIONS: readonly CoreResourceOperation[] = [
  CoreResourceOperation.READ,
  CoreResourceOperation.LIST,
  CoreResourceOperation.SEARCH,
  CoreResourceOperation.COUNT,
];

Ordinary resource operations inherit the predicate. The MongoDB representation uses a negative comparison that includes records whose marker is missing; PostgreSQL compiles the same meaning with NULL-inclusive semantics. This matters when introducing retention to existing records: unmarked active rows must not disappear.

Use the framework repositories and chart/query mechanisms. A custom raw database read is a separate disclosure surface and must consume the canonical predicate; authoring a similar-looking condition creates a second policy that can drift. The repository guards and check-retention-hide-reach check cover the framework’s known alternate paths.

Treat the marker as lifecycle state, not a UI filter. Hiding a table row in the browser leaves direct requests and aggregates unaffected. For a justified retained-data inspection, use readRetainedAsSystem or its list sibling with human attribution; the exception remains read-only and scoped.

Review retained information with a reason and a record Mechanism

Retained information can need review without returning it to ordinary use. Wildo provides a distinct read that requires a privileged person and a reason, while preserving the record’s scope.

The review leaves an attributable audit event. If that disclosure cannot be recorded, the result is withheld.

Example: Investigate a retained record

An authorized reviewer supplies their identity and investigation reason. They can inspect the retained row through the dedicated path without reopening it to normal editing or search.

An authorized reviewer supplies a reason and leaves an audit receipt before reading retained records through a dedicated read-only view.
For engineers

The application must authenticate the reviewer and derive their roles from trusted authorization state. Never accept the following attribution fields as unchecked browser claims. This actual method connects the entry gates, scoped read and audit-before-return sequence:

Source: system-access.backend.service.ts (selected excerpt).

  public async readRetainedAsSystem<T = any>(
    resourceType: ResourceType,
    id_or_ids_or_filter: IdOrIdsOrFilterType,
    auth: { accessingUserId: string; accessingUserRoles: string[]; reason: string },
    options?: ServiceOptions & {
      scopeContext?: { organizationId?: string; applicationId?: string; userId?: string };
    },
  ): Promise<T | null> {
    this.assertSeeRetainedAllowed(resourceType);
    this.assertSeeThroughRole(auth.accessingUserRoles);
    this.assertSeeThroughAttribution(auth.accessingUserId, auth.reason);

    const operationPath: ResourceConfiguration_OperationPath<any, any> = options?.variantKey
      ? this.resourcesRegistry.getServiceOperationPath(resourceType, CoreResourceOperation.READ, options.variantKey)
      : this.resourcesRegistry.getServiceOperationPathDefault(resourceType, CoreResourceOperation.READ);

    const systemContext = await this.executionContextCreator.createForInternalOperation({
      operation: operationPath,
      contextResourcesFieldIdentifier: this.buildSystemScopeContextMap(options?.scopeContext),
      initiatorIds: this.buildSystemInitiatorIds(options?.scopeContext),
      initiatorRoles: [
        {
          roles: [CORE_APP_ROLES.APP_ADMIN_SUPER_ADMIN],
          relatedPrimaryScope: ResourcePrimaryScope.APPLICATION,
        },
      ],
    });
    // M5b: reveal retained rows for THIS read only (suppresses ONLY the retention predicate, not scope).
    systemContext.setSeeRetainedData(true);

    const result = await this.getServicesRegistryHandler().read<T>(resourceType, systemContext, id_or_ids_or_filter, options);

    // M6: HUMAN-attributed, reason-bearing, and ACCOUNTABLE — an unrecordable reveal is refused.
    await this.emitRetainedDataRevealAudit({
      accessingUserId: auth.accessingUserId,
      roles: auth.accessingUserRoles,
      reason: auth.reason,
      resourceType: resourceType as string,
      operationIdentifier: 'readRetainedAsSystem',
      retainedRecordCount: result == null ? 0 : 1,
      organizationId: options?.scopeContext?.organizationId,
    });

    return result;
  }

The resource must allow retained-data access. readRetainedAsSystem accepts an ID, IDs or a filter; listRetainedAsSystem handles collection review. Supply scopeContext where required. The implementation suppresses only the retention filter for the read, not tenant isolation.

After reading, it emits RETAINED_DATA_ACCESS with the human ID, roles, reason and result count before returning the result. Audit failure refuses the reveal. This is stronger than logging a warning after handing over the data.

The exception does not provide an update door and does not make retained records visible to subject exports. Design the application’s review screen and request approval around this backend primitive, and keep investigation reasons useful without copying the sensitive record into the reason.

Authorize the caller before creating the internal context

For a HUMAN caller, this service checks auth.accessingUserRoles for CORE_APP_ROLES.APP_ADMIN_SUPER_ADMIN. A custom auditor role alone does not satisfy that guard. The role in the internal execution-context example is the authority used after admission; supplying such an internal context is not a substitute for authorizing the human request.

The resource must separately allow retained review. Preserve subject attribution and scope, and await the access audit before returning retained values. These controls make the exceptional read accountable without reopening ordinary list or read paths.

Give temporary records a clear expiry Mechanism

Some records serve a temporary purpose and should not accumulate indefinitely. Wildo lets a date field declare when its row expires, with shared semantics for an absolute deadline or an age-based window.

Scheduled cleanup turns that declaration into removal and reports bounded work for the operator to monitor.

Example: Expire a temporary job payload

A job stores a large payload with an expiry timestamp. Once it is due, the expiry mechanism can remove the row instead of leaving the sensitive payload as permanent operational history.

A temporary payload reaches its deadline and is removed through scheduled cleanup with an outcome.
For engineers

The actual job payload schema uses an absolute deadline. expireAfterSeconds: 0 means the field itself is the expiry instant; a positive value measures a window from that field:

Source: jobs.shared.schemas.ts (selected excerpt).


  // === Lifecycle ===
  /** When the payload was stored */
  createdAt: z.date().isDBIndexed(),
  /**
   * When the payload expires and can be cleaned up.
   * MongoDB TTL index will automatically delete documents at this time.
   */
  expiresAt: z.date().ttl({ expireAfterSeconds: 0 }),

MongoDB can enforce the TTL index. The shared retention sweep consumes the same .ttl() declaration and supports stores that do not remove rows natively. For an application resource, register a resource-anchored sweep manifest and its batch; a date annotation alone is not a scheduled PostgreSQL worker. Existing engine payload/execution sweeps are registered at startup.

collectResourceRetentionPolicies reads the declaration and resolveRetentionCutoff calculates the due boundary. The batch deletes bounded groups and reports when a run reaches its ceiling. Its schedule and backlog determine the actual cleanup time, so expiry is not a promise of deletion at the exact timestamp.

This is row expiry, not erasure retention’s keep/hide/freeze behavior and not a written retention-policy document. Choose a time field whose meaning matches the commitment, keep the worker operational and verify the stored result. File objects, backups and external services have their own retention paths; a row TTL does not remove every copy of its contents.

Connect your commitments to the product behind them

Assurance brings together what the application promises, how it is configured and the evidence that supports its behavior. Wildo gives those commitments named objectives and a shared technical basis.

Describe the operator, the markets served and the processors involved. Follow controls to their implementation and evidence, while keeping application decisions and independent assessment distinct.

Product controls, operator declarations and operating evidence contribute separately to review.

Make the assurance story inspectable

Give commitments a concrete basis

Named objectives connect the programme to implementation and evidence expectations, so a reviewer knows what each commitment means.

Describe the operating context

Operator identity, jurisdictions and processor declarations explain whose responsibilities and data flows the programme covers.

Follow the evidence

Source references and evidence requirements identify what to inspect. A configured mechanism, an observed result and an assessment retain their different meanings.

Example: Explain how sensitive uploads are handled

A file field requires scanning before use. Its configuration states the policy; the file lifecycle applies it; the scanner result supplies evidence for that occurrence. The programme can connect this behavior to a control without treating one successful scan as an assessment of the entire service.

For engineers

Keep the layers connected

LayerWhat it contributesWhat to check
Framework basisNamed objectives, capability bindings and evidence requirementsThe basis version and enabled capabilities
Application declarationsProduct configuration, operator identity, operating reach and recipientsWhether the declaration matches this application’s actual arrangement
Operating evidenceEvents, execution records and performed reviewsThe target, time, scope and source of the observation
AssessmentInterpretation of the evidence against the programmeThe evaluator’s authority and relevant independence

The programme selects objectives against a specific authority basis. createAssuranceProgramValidationAuthorities derives available contributions from application roles, enabled engine capabilities and declared host artifacts. This makes a framework capability different from a commitment the application can actually support.

Read evidence requirements before collecting

A requirement body names its obligation, evidence home, execution plane, minimum evidence and verification steps. Follow those fields to the right source: a configuration file, durable event store, application record or performed review. Do not infer evidence from the feature’s name, or interpret requiredProofStage as an achieved result.

The intended independent assurance workflow separates deterministic collection from judgment. It evaluates source evidence against the requirement, retaining reproducible observations and the reasons for a finding. External assessment remains a distinct authority; internal evaluation does not confer certification.

Record the operator’s choices in their owning declarations

operatorComplianceIdentity carries identity, role allocations and purpose-specific declarations. operatingJurisdictions records where the service operates and why. The processor register connects recipient disclosures to configured providers while retaining separately authored relationships. Those declarations prepare a consistent source for documents; they do not enact the policies they describe.

Source markers lead a reviewer to implementation. Concrete controls such as required file scanning apply their own runtime policy and lifecycle checks. Review the relevant mechanism and its evidence together, rather than treating either a marker or a document as proof by itself.

Keep a finding useful as the application changes

The intended assurance service runs independently for each application, using scoped machine access to gather evidence. Collection records observations; evaluation judges them against a named obligation. A finding keeps its evidence, reasoning, scope and stable identity so a repeated observation updates the same matter. Reassessment can resolve it when supporting evidence changes.

The operator receives committed findings and an aggregated notification of changes, with immediate escalation reserved for critical severity. Measurement and judged severity remain separate: a missing evidence export does not automatically mean the underlying control failed. Calibration includes planted failures and false-positive cases; an uncalibrated judgment must remain identifiable. This internal assurance loop does not replace an external assessor.

The security-event paper run is a historical design example: it found that the store contained application-scoped events beyond the organization API’s reach. Its classification concerned evidence accessibility, not the claim that those events had never been emitted. The artifact carries its application revision, collection window and achieved review independence; it is not a statement about today’s deployment.

Define the commitment and its evidence

Give each assurance commitment a clear meaning Guarantee

“We have a control” is useful only when people agree what it must do. Wildo defines objectives together with implementation expectations and the evidence needed to evaluate them. An application builds its assurance programme from that shared basis.

This keeps the framework’s contribution visible alongside the application’s own responsibilities. Selecting an objective, configuring its mechanisms and demonstrating that it operates are separate steps.

Example: Know what an access review must demonstrate

The framework supplies a scoped population of privileged users. The programme can also require a review occurrence: who reviewed that population, what was decided and where the outcome is retained.

A shared objective connects the mechanism to implement, its operation and the evidence needed to evaluate it.
For engineers
Build the authority snapshot from the application

createAssuranceProgramValidationAuthorities accepts the basis, enabled capabilities, application roles and host artifacts. This source excerpt shows the availability decision; omitted snapshot fields carry the same basis’s implementation and evidence expectations.

export function createAssuranceProgramValidationAuthorities(input: {
  readonly basis: unknown;
  readonly hostFacts: AssuranceProgramAuthorityHostFacts;
  readonly evaluatedAt: string;
}): AssuranceProgramValidationAuthorities {
  const basis = assertAssuranceProgramAuthorityBasisIsCoherent(input.basis);
  for (const artifact of input.hostFacts.hostImplementationArtifacts) {
    AssuranceProgramHostImplementationArtifactSchema.parse(artifact);
  }
  const enabledCapabilities = new Set(input.hostFacts.enabledEngineCapabilities);
  const snapshot = AssuranceProgramValidationAuthoritySnapshotSchema.parse({
    schemaVersion: 1,
    snapshotRef: basis.basisRef,
    snapshotVersion: basis.basisVersion,
    evaluatedAt: input.evaluatedAt,
    requestedSourceUses: basis.requestedSourceUses,
    sourceRegistry: basis.sourceRegistry,
    documentAuthorityCatalog: basis.documentAuthorityCatalog,
    objectiveCatalog: basis.objectiveCatalog,
    applicationRoleRefs: [...new Set(input.hostFacts.applicationRoleRefs)].sort(compareCanonicalText),
    capabilityAuthorities: basis.capabilityAuthorityDescriptors
      .filter(({ availability }) => isAssuranceCapabilityAvailable(availability, enabledCapabilities))
      .map(({ authority }) => authority),
    implementationArtifacts: basis.implementationRevision.artifacts,
    hostImplementationArtifacts: [...input.hostFacts.hostImplementationArtifacts].sort(
      (left, right) => compareCanonicalText(left.artifactKindRef, right.artifactKindRef) || compareCanonicalText(left.artifactRef, right.artifactRef),
    ),
    sourceRequirementAuthorities: basis.sourceRequirementAuthorities,
    implementationExpectationAuthorities: basis.implementationExpectationAuthorities,
    evidenceExpectationAuthorities: basis.evidenceExpectationAuthorities,
    verificationExpectationAuthorities: basis.verificationExpectationAuthorities,
  });
  return Object.freeze({ snapshot });
}

The application’s capability selections constrain which framework contributions it can claim. Host artifacts supply application-owned evidence contracts. A basis version identifies the interpretation of those commitments, so a programme can be reviewed against a specific framework generation.

Keep proof stages distinct

The programme uses an explicit ladder:


export enum AssuranceProgramProofStage {
  SELECTED = "SELECTED",
  CONFIGURED = "CONFIGURED",
  OPERATING = "OPERATING",
  EVIDENCED = "EVIDENCED",
  ASSESSED = "ASSESSED",
}

requiredProofStage expresses the programme’s target. It is not an observation that the target was reached. A configuration can establish CONFIGURED; an execution must substantiate operation; evidence must support the claimed period and scope. External assessment has its own authority.

Review the complete obligation

For each objective, read the bound capability references, implementation expectation, evidence requirement body and verification expectation together. A security event can prove an action occurred without proving that a periodic review occurred. Application owners provide the latter contract where it belongs. The intended assurance workflow can collect and evaluate these distinct records without flattening them into a single “compliant” flag.

Follow a control to the code that supports it Guarantee

An assurance claim should lead somewhere concrete. Wildo places named control references beside the code that supports them, so an engineer can follow an objective to its authorization checks, audit records or data-handling rules.

The reference gives implementation and assurance work a common language. Review still examines what the code actually does; the marker makes that examination traceable.

Example: Review who can administer a customer workspace

A reviewer follows the privileged-access objective to the function that enumerates current administrators and owners. That population supports the review; the review decision and its retained record remain separate evidence.

A control reference points to an implementation site and the evidence and review associated with it.
For engineers
Start with the named objective

The real privileged-access implementation identifies its objective in the source. Its result deliberately separates application administrators, customer owners and customers that could not be inspected. Selected declarations from privileged-access-review.backend.utils.ts:

// assurance-control: WILDO.ACCESS.PRIVILEGED_REVIEW
export interface PrivilegedAccessPopulation {
  readonly observedAt: string;
  readonly applicationScopeConferringRoles: readonly string[];
  readonly organizationScopeConferringRoles: readonly string[];
  readonly applicationScopeHolders: readonly PrivilegedApplicationAccessHolder[];
  readonly organizationScopeHolders: readonly PrivilegedOrganizationAccessHolder[];
  readonly unreadableOrganizationIds: readonly string[];
}
Review the behavior the marker points to

The backend startup diagnostic is a concrete caller: it resolves conferring roles from the current hierarchy, supplies the observation time and invokes enumeratePrivilegedAccessPopulation. The enumeration reads usable account and membership populations. The resulting population is input to an accountable review, not a declaration that every listed permission is appropriate. Keep the decision, reviewer and follow-up work in the application’s assurance evidence.

Keep the references checkable

The repository checker validates marker syntax and objective membership and separates external mapping terminology from runtime controls. It also checks that its workflow covers its scan roots. Run pnpm check:assurance-control-boundary when changing control markers. Passing this check establishes referential hygiene; behavioral review and evidence establish the control.

Know where to look for proof Tool

A declaration, an audit event and a completed review answer different questions. Wildo’s assurance model distinguishes where the evidence lives and whether it describes configuration, a driven check or real operation.

That distinction turns an assurance request into a practical investigation: what to collect, from which source, over which scope and period.

Example: Confirm that a security event was recorded

For an authentication event, inspect the durable event record and its attribution. A setting that enables the audit trail is useful context, but it cannot stand in for the event itself.

A reviewer follows the requirement to a configuration file, event store or performed review record.
For engineers
Follow the authored evidence body

The security-event requirement identifies an actual durable-record home and a production plane. The excerpt below is from assurance-evidence-requirement-bodies.definitions.ts; the surrounding applicability text conditions the demand on the audit capability being enabled.

clauses: [
  {
    clauseRef: "C1",
    obligation:
      "Authentication, authorization, privilege, credential and security-configuration changes each emit a typed auditable event.",
    home: AssuranceEvidenceHome.EMITTED,
    plane: AssuranceEvidenceExecutionPlane.PRODUCTION,
    homeRationale:
      "The demanded artifact is a per-occurrence row in the engine's own durable event store, so one collector query "
      + "serves every application. Not read off the attesting capability: it is read off the artifact the clause names.",
    requiredAuditEventMembers: [
      // authentication
      CoreAuditableEventType.USER_LOGIN,
      CoreAuditableEventType.USER_LOGIN_FAILED,
      CoreAuditableEventType.USER_LOGOUT,
      CoreAuditableEventType.USER_SESSION_EVICTED,

Its remaining event members, minimum-evidence description and verification steps define the rest of the requested population. A collector must inspect the store of record for that demand; a filtered convenience view is not necessarily the same population.

Match the evidence to the question
EvidenceWhat it can establishContext to retain
Committed declarationThe selected configuration or policyApplication and framework revision
Durable event or execution recordAn occurrence and its outcomeActor, target, time and relevant scope
Driven verificationBehavior under the exercised scenarioScenario identity and tested version
Review or provider artifactThe performed review or external observationResponsible party, period and source

The body’s home and execution plane are authoritative. The repository’s older assurance:evidence-homes report derives proxy classifications from bindings and names; use it for exploration, not to override an authored clause.

Separate collection from judgment

The intended independent assurance workflow collects source artifacts before judging satisfaction. An unavailable export and a control that failed are different findings. A review should cite reproducible observations, preserve uncertainty and explain severity separately. This model defines what an evaluator needs; it does not turn an unexecuted demand into evidence.

Describe the operator and the service

State who operates the service Mechanism

A person reading a notice needs to know who runs the service and how to reach them. Wildo gives that information a declared home, alongside the operator’s role for each population of data and its recorded processing purposes.

A business application can hold customer records and its own account information under different responsibilities. The declaration keeps those distinctions visible instead of assigning one role to everything.

Example: One product, different responsibilities

A CRM operator records one role for the customer’s contact records and another for its own account administration. The notice can explain each population without combining them into one generic statement.

An operator’s responsibilities distinguish customer-record processing from account administration.
For engineers
Start with the operator and its data populations

This configuration excerpt comes from the Wonder CRM demonstration application. The identity and role choices are example operator declarations, not defaults or legal advice.

operatorComplianceIdentity: {
  legalEntityName: 'Wonder Software SAS',
  registeredAddress: '1 rue de la Paix, 75002 Paris, France',
  privacyContact: 'privacy@wonder-crm.example',
  roleAllocations: [
    {
      dataScope: 'customer and contact records a subscribing team stores in its workspace',
      role: OperatorProcessingRole.PROCESSOR,
    },
    {
      dataScope: 'account, authentication and billing data of the team members who administer and use the workspace',
      role: OperatorProcessingRole.CONTROLLER,
    },
  ],

Import the named role vocabulary from @wildo-ai/saas-models; place the declaration under operatorComplianceIdentity in wildo.saas.config.ts. Lawful bases are recorded per purpose in the sibling lawfulBases array, with an assessment note required by the schema for a declared legitimate-interests basis.

serviceConsentRequirement describes an explicit consent gate and what it governs. It is separate from analytics preferences and from the purpose-specific lawful-basis declaration. Declaring NONE is an answer; omission is not a claim that no gate exists. The identity schema permits independent parts to be recorded as they become established while rejecting an empty declaration.

Use the declaration in reviewable documents

The companion composes these values into document-authoring context. outstandingOperatorComplianceIdentityParts identifies which identity fields remain unanswered. The declaration supplies the operator’s stated facts and choices; it does not change request authorization, enact the processing policy or validate a legal conclusion. Product behavior and the published account of it must be reviewed together.

Make the places you serve explicit Guarantee

Where the operator is established and where the product serves people shape the questions its documents must address. Wildo records that reach explicitly rather than guessing it from a currency, language or address.

The declaration can be compared with the reach of registered sources. This supports focused research and review without confusing source coverage with compliance.

Example: Expand into another market deliberately

An application adds a country to its operating declaration and records why it serves that market. Its document workflow can then evaluate that reach, instead of silently reusing the assumptions of the previous market.

A product’s declared European and US operating contexts lead to corresponding document considerations.
For engineers
Record the reason beside each jurisdiction

This is the operating declaration from Wonder CRM. It illustrates the configuration shape, not a recommendation about any application’s legal position.

operatingJurisdictions: {
  jurisdictions: [
    {
      jurisdictionSlug: 'eu',
      basis: 'the operator is established in France and subscribing teams and their members are in the EU',
    },
    {
      jurisdictionSlug: 'gb',
      basis: 'teams established in the United Kingdom subscribe and store their customer records here',
    },
  ],
},

The schema requires a nonempty jurisdiction list and a reason per place, and rejects duplicates. Canonical region spelling matters: aliases should not create two identities for one place. A subdivision means a place inside its root; broader regimes are represented by their component places.

Read coverage as source reach

assessAssuranceJurisdictionCoverage compares the declared places with active registered sources. composeOperatingJurisdictionCoverageBrief carries that result into drafting context. It distinguishes law-backed coverage, guidance-only coverage and uncovered reach. Those states say what source material the framework carries; none establishes that the application meets the source’s requirements.

Keep publication and operating scope connected

Operating reach answers where the product acts. A public-document variant answers which exact language and jurisdiction combination a document is prepared for. Use the same canonical jurisdiction vocabulary in both. A reviewer still determines the requirements and statements appropriate to the operator’s actual activities.

Explain who else receives the data Mechanism

A provider name alone does not explain who receives personal data. Wildo gives the application a processor register for the actual arrangement: the legal entity, processing locations and the next organizations in the chain.

Entries can be connected to configured providers or describe other operator-managed relationships. That makes the register useful both for technical coherence and for preparing a clear recipient disclosure.

Example: Follow an email beyond the application

An application sends account emails through a provider. Its register identifies the processor and relevant locations, while the configured-provider reference connects that disclosure to the service the application actually uses.

An application’s data flow connects to a processor and subprocessor with their processing regions recorded.
For engineers
Choose the right kind of entry

The canonical schema makes configured providers and separately authored relationships distinct. Both carry the same identity fields; only the provider-backed kind can be joined to application configuration. Selected schema declarations from processor-register.schemas.ts:

const ProcessorIdentityFieldsSchema = z.object({
  legalEntityName: z.string().trim().min(1),
  processingRegions: z.array(z.string().trim().min(1)).min(1),
  subprocessors: z.array(ProcessorSubprocessorSchema),
});

export const ApplicationProcessorDeclarationSchema = z.discriminatedUnion("kind", [
  ProcessorIdentityFieldsSchema.extend({
    kind: z.literal(ProcessorRegisterEntryKind.CONFIGURED_PROVIDER),
    providerRef: z.string().trim().min(1),
    purpose: z.string().trim().min(1).optional(),
  }).strict(),
  ProcessorIdentityFieldsSchema.extend({
    kind: z.literal(ProcessorRegisterEntryKind.AUTHORED),
    subprocessorRef: z.string().trim().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
    purpose: z.string().trim().min(1),
  }).strict(),
]);
Reconcile both directions

The helpers below show the result the drafting lane can inspect. The register is a typed specification family, loaded from the application’s specification exports.

The companion supplies runtime-bound provider references and the operator’s deployment choices. It excludes explicitly self-hosted providers before comparing recipient candidates with the register; an unanswered deployment choice stays visible as a question.

// Selected companion reconciliation; all inputs have already been loaded.
const operatorRunRefs = new Set(
  Object.entries(choices ?? {})
    .filter(([, choice]) => choice.deploymentModel === ProviderDeploymentModel.SELF_HOSTED)
    .map(([providerRef]) => providerRef),
);
const recipientCandidateRefs = configuredProviderRefs.filter((ref) => !operatorRunRefs.has(ref));
const missing = undeclaredProcessorRefs(recipientCandidateRefs, register);
const stale = processorRefsNotConfigured(recipientCandidateRefs, register);
const regions = processorProcessingRegions(register);

The reconciliation helpers are exported from @wildo-ai/saas-specifications. missing identifies candidates without entries, not proof that each is vendor-hosted. stale identifies provider-backed entries outside that candidate set. regions includes declared subprocessors. Profile-only providers never enter the runtime-bound input. Separately authored relationships require their own declaration because configuration cannot establish them.

Record the arrangement, not a brand assumption

A configured-provider entry carries kind, providerRef, legalEntityName, processingRegions and subprocessors; authored entries use subprocessorRef and a required purpose. An empty register records the operator’s declaration of no processor recipients, which must still be reconciled against candidates; an absent register has not made that statement. Provider deployment choices determine whether an external recipient exists at all. Verify the operator’s actual contract and source disclosures before publication; schema validation establishes consistency, not the truth of a vendor’s current arrangements.

Apply controls and explain them

Keep a file unavailable until its scan finishes Mechanism

Accepting a file and allowing people to use it are different steps. Wildo can place an uploaded file into a scanning state before making it available, then release or quarantine it according to the result.

The field chooses whether scanning is required or skipped; configured scanner infrastructure supplies the checks. Standard forms show that processing is still underway and wait before adding the new file to the record. A rejected file stays out of the record, with an error the person can act on.

Example: A document waits for a clean result

A contract finishes uploading while its scan is still running. The standard form keeps the new attachment pending and blocks saving until processing resolves. A clean result lets the person save the record with its attachment. An infection or scanner error leaves the file unavailable; the person can discard the rejected selection and choose another file.

An attachment waits for scanning before becoming downloadable.
For engineers
Declare policy and provision its scanner

Use the file’s scanPolicy for an explicit requirement or exception. An unspecified field inherits the application’s configured scanner posture. The backend reads this decision in file-scanning.backend.service.ts; the separate global predicate is included, with intervening commentary omitted.

shouldScan(fileMetadata?: FileSchemaMetadata): boolean {
  const scanPolicy = fileMetadata?.scanPolicy;

  if (scanPolicy === 'required') return true;
  if (scanPolicy === 'skip') return false;

  return this.isGlobalScanningEnabled();
}
isGlobalScanningEnabled(): boolean {
  if (!this.appConfigService.isInitialized()) return false;
  return this.clamavScanner.isConfigured() || this.yaraScanner.isEnabled();
}
Move into scanning before dispatching work

The upload handler awaits scan initiation before returning upload acceptance and file metadata. Upload acceptance does not mean the scan has finished: file.readiness reports whether the returned file can be attached. Inside the scan service, the status transition occurs before asynchronous scanner execution. This excerpt omits validation above and the error-logging callback below.

await this.filesService.updateFileStatus(
  fileId,
  FileStatus.SCANNING,
  executionContext,
);

if (onClean) {
  this.onCleanCallbacks.set(fileId, onClean);
}

this.logDebug('File transitioned to SCANNING, dispatching scan', { fileId });
Distinguish a clean result from a failed check

Configured ClamAV signature scanning runs first; enabled YARA pattern scanning follows if the earlier layer has not refused the file. The pipeline requires at least one configured scanner when scanning is requested. Startup validation reports incompatible required-field and scanner configurations, and the runtime also refuses to certify a file clean when no scanner is available.

A clean verdict transitions the file to CLEAN and releases configured follow-on work. An infection moves it through INFECTED toward QUARANTINED; quarantine keeps the stored bytes but blocks normal download. A scanner or storage error moves it to FAILED, requiring recovery or re-scan. The same state checks stop linking while scanning. Standard file controls refresh pending metadata and publish the new reference only after it becomes ready; the form also preserves the processing error through submit validation. Scanning reduces malware risk; it does not validate the business meaning or confidentiality of a document.

Read readiness before attaching a file

Upload responses include file.readiness; the authorized metadata endpoint returns the same FileMetadata contract. The standard single- and multiple-file controls handle this for local files and provider selections. Custom upload interfaces must inspect readiness rather than treat HTTP success as permission to attach.

file.readiness valueMeaning for the form
readyThe file may be submitted as a new attachment, subject to server authorization
processingRefresh metadata; do not submit the new reference yet
attachedRetain an existing attachment; this does not authorize attaching it to another record
unavailableDo not submit the new reference; show the failure and allow another selection

Readiness describes attachment eligibility, not download authorization. The backend still checks access and lifecycle state when linking or serving a file. Polling observes a scan; it does not start a new scan or automatically retry a failed one. With scanning skipped or inactive, an ordinary completed upload can already report ready.

Match field policy to the startup contract
Field policyRequired setupResult
requiredConfigured ClamAV; YARA alone does not satisfy startup validationThe upload must pass scanning
Unspecified / inheritedApplication scanner posture; configured ClamAV or enabled YARA activates global scanningInherits that posture
skipExplicit field exceptionSkips this scanning path
Scan outcomeFile state and evidence
CleanCLEAN, with configured follow-on work released; this path does not emit a separate clean audit event
MalwareInfection/quarantine handling and a malware audit event
Scanner or storage failureFAILED and failure evidence; an error is not a clean verdict

Use status for current download eligibility and the emitted failure/malware events for investigation. The absence of a clean-event row must not be interpreted as proof that no scan ran.

Connect a required field to the deployed scanner

This illustrative attachment field requires scanning, independently of the inherited application posture:

import { z_file } from '@wildo-ai/zod-decorators';

const attachment = z_file({
  multiple: false,
  allowedMimeTypes: ['application/pdf'],
  scanPolicy: 'required',
});

Provision a ClamAV daemon reachable from the backend, then supply its address through the application’s environment setup. For example, these values assume the backend can resolve a service named clamav; setting them does not install or start the daemon:

CLAMAV_HOST=clamav
CLAMAV_PORT=3310

The configuration loader seeds fileScanning.antivirus; the scanner reads that resolved configuration. Startup validation checks required fields against ClamAV configuration. A configured address is not proof that the scanner is healthy: the upload path still needs the daemon to accept and complete a scan.

For the additional YARA layer, provision the YARA scanning service and its rules, then configure both the enable flag and its reachable address:

YARA_ENABLED=true
YARA_HOST=yara
YARA_PORT=8080

Those names are illustrative deployment addresses. The YARA service consumes fileScanning.yara.enabled and fileScanning.yara.connection; enabling it without a host is a configuration problem. YARA alone does not satisfy the current startup requirement for a field marked required.

What you configureWhat to verify
Required field plus ClamAVStartup accepts the configuration; an uploaded file moves through processing to a clean or refused outcome
Additional YARA serviceEnabled flag, reachable service, and the intended rules; a clean ClamAV result does not bypass an enabled second layer
Custom upload interfacePoll authorized metadata while readiness is processing; submit the reference only when ready
Scanner outageThe file must not be treated as clean merely because the upload request succeeded

Retain the uploaded file ID while observing processing. Repeated metadata reads observe the existing scan; uploading again creates another upload rather than repairing the first scan.

Relate controls to external requirements Guarantee

An application may need to explain the same control to different reviewers. Wildo keeps its control objectives stable and places external source mappings in a separate reporting layer.

Mappings carry references, attribution and permitted use. The application’s access checks and data handling keep their own meaning instead of depending on a certification label.

Example: Explain an existing control to a new reviewer

A security control already has an implementation and evidence expectation. A mapping supplies the relevant external reference without changing the code path or suggesting that a mapping itself certifies the application.

Wildo objectives map to external review frameworks while the product runtime retains its own control definitions.
For engineers
Resolve the source and its permitted use

deriveAssuranceExternalFrameworkMapping joins source requirements to the source registry. Before emitting the citation, it checks the registered permission at the evaluation time. Comments between these lines are omitted.

if (!metadataCitationAuthorized) {
  throw new AssuranceExternalMappingRightsError(
    `Cannot cite "${authority.source.sourceRef}": its rights record does not authorize STORE_METADATA as of ${evaluatedAt}. `
    + "Resolve the rights before publishing a mapping that names it.",
  );
}
const contentUsePermission = source.rights.useDecisions.find((decision) => decision.use === AssuranceSourceUse.EXTRACT_CONTENT)?.permission
  ?? AssuranceSourceUsePermission.UNKNOWN;
attributionNotices.add(source.rights.attributionNotice);
const existing = citationsByObjective.get(authority.objective.objectiveRef as string) ?? [];
existing.push({
  sourceRef: authority.source.sourceRef as string,
  sourceTitle: source.canonicalTitle,
  authorityKind: source.authorityKind,
  requirementRef: authority.requirementRef as string,
  canonicalLocator: authority.canonicalLocator,
  reviewedByRef: authority.reviewedByRef as string,
  reviewedAt: authority.reviewedAt,
  rightsRef: source.rights.rightsRef as string,
  attributionNotice: source.rights.attributionNotice,
  metadataCitationAuthorized,
  contentUsePermission,
});
citationsByObjective.set(authority.objective.objectiveRef as string, existing);

The result keeps the source locator, review attribution and content-use permission. Metadata permission does not automatically authorize copying source text. A source’s status and rights must support the use the report actually makes.

Keep the direction of dependency clear

Runtime code names Wildo objectives. The reporting boundary maps those objectives outward to sources; source names do not select production behavior. The generation lane can emit the mapping beside the basis it was derived from, keeping both tied to the same inputs.

Interpret a mapping as a reference

A mapping helps scope a review. It is not a legal conclusion, a complete assessment of applicability or an external certification. The application programme determines its commitments and supporting evidence; qualified assessment determines what those records establish in the relevant context.

Make your documents describe the application people use

Notices and terms need a consistent account of the product, its operator and its handling of data. Wildo connects document preparation to declared facts and the concerns each document must address.

Facts retain their sources; clauses explain them for readers. Language and market choices remain explicit, so drafting, review and publication can follow the same intended audience.

Resources supply facts for an audience-specific document whose source links remain available for review.

Turn product knowledge into reviewable content

Begin with the product

Read resource and operator declarations before drafting. Keep technical behavior separate from decisions that need an accountable owner.

Keep the sources attached

Stable fact references connect readable clauses to their basis. Coverage checks ask whether the document addresses the required concerns as well as citing valid facts.

Prepare for the right audience

Declare exact language and jurisdiction combinations. A prepared candidate is content for review, with publication handled deliberately.

Example: Revise a notice as the data model evolves

A product adds contact information to a resource. Introspection reads the updated data declaration; the fact layer records its meaning; the notice can cite that fact in the appropriate section. Review checks the statement and its coverage before the operator publishes the revised content.

For engineers

Give each artifact one job

ArtifactQuestion it answersSource or consumer
Resource introspectionWhat data handling does the application declare?Loaded backend resource configuration
Document factsWhat statement is established, for which scope and under whose authority?Derived specification or attributed decision/review
Document candidateHow should those facts be explained to a reader?Clauses citing facts and addressing blueprint concerns
Publication variantsWhich language and jurisdiction combinations are intended?Application publication specification

refinement.document-facts receives mechanism facts and fact obligations. refinement.compliance-document-candidate receives the declared facts and document blueprint. That boundary lets the prose evolve without asking the writer to silently invent a new product fact.

Validate meaning-bearing references

Candidate validation distinguishes valid shape, fact availability and required coverage. A valid reference alone does not prove that the document covers its obligations; complete coverage alone does not prove that each sentence is accurate. Sections can identify their concern and required blueprint sections; clauses can identify required blueprint clauses, source facts and review guards.

Derived facts, operator decisions and qualified reviews have distinct authority forms. Review checks that the source supports the claim. A model-generated authority label is not independent verification of the underlying fact.

Keep preparation and publication distinct

The content model supports iteration with explicit completeness and reviewable source links. Where a clause awaits a named fact, validation can detect that an old absence statement needs revision when the fact arrives. The intended destination is product-derived content supported by operating evidence and declared decisions, not a questionnaire that substitutes for the product.

Publication variants keep locale and jurisdiction together. Validate their canonical identities and use the corresponding reviewed content in the application’s release process. A generated candidate is not automatically approved, legally sufficient or published.

Establish the product facts

Read the privacy facts from the application Tool

The application already declares how its records are handled. Wildo can read those declarations back instead of asking a document drafter to reconstruct them from a product description.

The result separates framework-owned resources from application-owned resources. It also keeps personal-data handling, session identity and available rights mechanisms distinct, so one answer cannot quietly stand in for another.

Example: Explain what happens to a contact record

A customer contact may carry personal information without having a login. The introspection result can describe its erasure treatment and export declaration while correctly reporting that there is no associated login session to end.

Resource declarations about data, erasure and export provide facts for document preparation.
For engineers
Read each resource through the same projection

readCompliancePrimaryResourceFacts receives the loaded resource configuration. Its result comes directly from named declaration fields and the personal-data category resolver, rather than a language-model inference.

export function readCompliancePrimaryResourceFacts(
  resourceType: string,
  origin: CompliancePrimaryFactOrigin,
  configuration: ComplianceRelevantConfiguration,
): CompliancePrimaryResourceFacts {
  const systemAccessPolicy = configuration.systemAccessPolicy;
  const categoryProfile = resolveResourcePersonalDataCategories(configuration.mainSchema);
  return {
    resourceType,
    origin,
    ...(configuration.retentionPolicy?.mode === undefined ? {} : { erasureRetentionMode: configuration.retentionPolicy.mode }),
    ...(configuration.dataSubjectPolicy?.kind === undefined ? {} : { dataSubjectKind: configuration.dataSubjectPolicy.kind }),
    declaresSubjectExport: systemAccessPolicy?.exportSubject !== undefined,
    declaresRetainedSeeThrough: systemAccessPolicy?.seeRetained !== undefined,
    personalDataCategories: categoryProfile.categories,
    uncategorizedPersonalFields: categoryProfile.uncategorizedPersonalFields,
  };
}
Give each output its proper meaning

erasureRetentionMode answers what happens to the row. dataSubjectKind identifies the session-related subject axis. declaresSubjectExport and declaresRetainedSeeThrough report whether the corresponding declaration is present. Both are also true for an explicit FORBIDDEN declaration: they do not mean the operation is allowed. Read the resource’s actual policy before describing availability; neither flag shows that a request has been exercised. Field categories describe the data declared in the schema, including fields still needing classification.

projectCompliancePrimaryFacts aggregates these values with separate engine/application origins. Stable category ordering avoids changing document prose merely because resource iteration order changes. An undeclared resource is distinct from an explicit declaration of no personal data.

Connect the projection to authoring

The companion requests the backend behavior named compliance-primary-facts. A playbook selecting COMPLIANCE_PRIMARY_FACTS receives that result in its brief. In this required context path, a missing backend or unsuccessful result stops fact drafting instead of replacing source facts with guessed prose. The facts playbook consumes mechanism declarations; the later document drafter consumes the resulting declared facts. Reading the loaded application configuration establishes what it declares, not operating effectiveness.

Keep the statement connected to its source Mechanism

A fact about the product should survive a rewrite of its notice. Wildo gives facts stable references, a scope and an authority, then lets document clauses cite them.

The document can be written for its reader while retaining the source behind each claim. A declared fact, an operator decision and a qualified review keep their different origins.

Example: Explain a consent setting without changing its meaning

The application declares whether service access has a consent gate. A notice clause cites that fact and explains the gate, without treating it as the lawful basis for every processing purpose.

A stable product fact is cited by the document statement that explains it.
For engineers
Give the fact an identity and authority

The document-facts family carries versioned declarations, governance scope, sensitivity, authority and either a known value or a reason it does not apply. This selected Wonder CRM declaration records the service consent setting, its value and source authority. The clause below cites this same fact.

"declarationRef": "document-fact-declaration:service-consent-gate",
"declarationVersion": 1,
"factRef": "document-fact:service-consent-gate",
"factVersion": 1,
"governanceScope": {
  "subjectKind": GovernedDocumentGovernanceSubjectKind.APPLICATION_OPERATOR,
  "subjectRef": "operator:wonder-software-sas",
  "scopeRef": "scope:wonder-software-sas-application",
  "scopeVersion": 1,
  "participantRefs": [],
  "precedenceRuleRefs": []
},
"sensitivityRef": "sensitivity:public",
"authority": {
  "authorityRef": "document-fact-authority:service-consent-gate",
  "authorityVersion": 1,
  "factAuthorityKind": AssuranceDocumentFactAuthorityKind.APPLICATION_SPECIFICATION
},
"resolutionKind": ApplicationAssuranceDocumentFactContributionResolutionKind.KNOWN,
"value": "Use of the service is not gated on consent. This is a service-wide consent-gate configuration and does not state a lawful basis for processing."

A generated specification fact cannot simply claim qualified-review authority. Authored decision/review facts have separate schemas. Qualified-review declarations require different author and reviewer references, qualification references and an independence disclosure. The reference inequality guard does not establish that two references identify different people or that the reviewer is independent. Those substantive checks belong to review; the schema preserves their declared attribution.

Cite the fact from a reader-facing clause

This actual clause in the demonstration notice references the consent-gate fact, with no duplicate policy citation:

{
  "clauseRef": "clause:service-wide-consent-gate",
  "order": 1,
  "bodyMarkdown": "Use of Wonder CRM is not gated on service-wide consent. This configured gate status does not identify a lawful basis for processing.",
  "completeness": ComplianceStatementCompleteness.DERIVED,
  "factRefs": [
    "document-fact:service-consent-gate"
  ],
  "sourcePolicyRefs": [],
  "claimGuardRefs": [
    "claim-guard:no-consent-basis-conflation",
    "claim-guard:known-fact-only"
  ]
},

factRefs names established source facts; sourcePolicyRefs names policy areas when needed. A clause must cite at least one source across those fields. claimGuardRefs records constraints for the reviewer, not an automatic proof of semantic truth.

Validate the connection

The candidate fact-binding validator checks cited facts against declared availability, including both clauses and variant selections. A known fact needs a substantive value; an explicit not-applicable declaration has its own reason. The resulting document remains a reviewable content artifact. Its presence is not publication or approval, and source consistency is not a substitute for checking the prose’s meaning.

Check what the document owes as well as what it cites
CheckQuestion it answers
Citation bindingDoes each cited fact resolve to available support?
Required-fact coverageDoes the document cover the declared facts required by its blueprint?
Marked clauseWhat is missing, and who can supply it?

A document can cite only valid facts and still omit a required subject. Coverage distinguishes an uncited required fact from one discussed only in an incomplete clause. The landing consumer scopes that check to declared facts; an unavailable blueprint or fact set is not proof of complete coverage.

This illustrative clause records an unresolved operating decision. Its policy and guard references must be chosen for the surrounding candidate:

import { ComplianceStatementCompleteness, type ComplianceDocumentCandidateClause } from '@wildo-ai/saas-specifications';

export const retentionDecision: ComplianceDocumentCandidateClause = {
  clauseRef: 'clause:retention-decision',
  order: 1,
  bodyMarkdown: 'The retention duration requires an operator decision.',
  completeness: ComplianceStatementCompleteness.NEEDS_DECISION,
  incompleteness: {
    needs: 'The retention duration for customer records.',
    closedBy: 'The operator responsible for the retention policy.',
  },
  factRefs: [],
  sourcePolicyRefs: ['policy:retention'],
  claimGuardRefs: ['claim-guard:retention-decision'],
};

A non-derived clause requires incompleteness; a derived clause must not carry it. When a known fact’s arrival would resolve the gap, name it in awaitingFactRefs so stale gap statements can be detected. Keep facts awaiting a human decision or qualified review in their authored family; regenerating application-derived facts must not replace those decisions. A marked draft is useful work for review, not permission to publish an unsupported statement.

Prepare the document and its audience

Write documents that describe the product Mechanism

A notice should explain the service people are using. Wildo organizes governed documents around the product’s facts and the concerns the document needs to address, rather than treating a plausible draft as a finished result.

Sections and clauses remain readable content with traceable sources. This gives reviewers a way to assess both what the document says and whether it covers the questions it is meant to answer.

Example: Update a notice when the product changes

An application adds a new category of personal data. The fact layer records the declaration, and the notice can cite it in the relevant section. Review can check the changed statement and the surrounding coverage together.

Product facts support document sections, with attached source references available to review.
For engineers
Keep structure and source references together

A ComplianceDocumentCandidate names its document and jurisdictions, then contains ordered sections and clauses. Sections identify the concern they address and may declare which blueprint sections they satisfy. Clauses carry prose, fact references, claim guards and completeness. These are the actual document-level fields of the Wonder CRM candidate:

export const applicationComplianceDocumentCandidate: ComplianceDocumentCandidate = {
  "version": ComplianceDocumentCandidateVersion.V1,
  "documentRef": "document:public.privacy-notice",
  "title": "Wonder CRM Privacy Notice Candidate",
  "jurisdictionRefs": [
    "jurisdiction:eu",
    "jurisdiction:gb"
  ],
  "variantSelections": [],
  "sections": [
    {
      "sectionRef": "section:operator-identity",
      "order": 1,
      "title": "Operator identity and privacy contact",
      "concernRef": "operator-identity",
      "satisfiesSectionRefs": [
        "document-section:scope-and-audience",
        "document-section:identity-and-role",
        "document-section:contact"
      ],

The candidate’s own section identity can stay readable while satisfiesSectionRefs links it to required coverage. A corresponding satisfiesClauseRefs field supports clause-level requirements without forcing the writer to copy the blueprint’s structure word for word.

Check coverage as well as valid citations

A document can cite only valid facts and still omit an important subject. validateComplianceDocumentCandidateFactBinding checks its references; fact/blueprint coverage and the required-document set answer different questions. The completeness projection reports concerns that are established, unresolved or unaddressed. Keep those checks separate so internal consistency cannot masquerade as complete coverage.

Carry unresolved statements honestly through review

The content schema distinguishes derived statements from information needing framework support, configuration or an operator decision. Where an unresolved statement names awaitingFactRefs, its arrival can be detected as a stale absence claim. This supports iterative document preparation without silently inventing an answer.

The destination is a document derived from product facts and reviewed decisions, with production evidence supporting claims about operation. The drafting process prepares content. The operator’s release process decides what may be published; qualified review addresses legal interpretation and external assessment retains its own authority.

Prepare the right document for each audience Mechanism

A translated notice and a notice prepared for another market are not interchangeable. Wildo models each publication variant as a language paired with its intended jurisdiction set.

This gives document preparation a clear audience contract. Adding a language or market is an explicit change, rather than permission to assemble every possible combination.

Example: Add a new audience without changing the existing one

An operator prepares one English variant for a defined regional audience. A second market gets its own declared combination, allowing the document and its review to follow that specific audience.

English for the US and French for France are two explicitly paired publication variants.
For engineers
Keep locale and jurisdiction together

This is the actual typed publication declaration in Wonder Todos, shown without its surrounding design commentary:

export const applicationDocumentPublicationSpecification = ApplicationDocumentPublicationSpecificationSchema.parse({
  schemaVersion: 1,
  authorityRef: "document-publication-variant-authority:wonder-todos",
  authorityVersion: 2,
  variants: [{ locale: "en-GB", jurisdictionRefs: ["jurisdiction:eu", "jurisdiction:is", "jurisdiction:li", "jurisdiction:no"] }],
});

The family is exported by the application’s specification companion exports. The authorityRef and version identify the declaration being reviewed; variants is a set of exact combinations, not two independent lists to cross-multiply.

Validate identity before using it

ApplicationDocumentPublicationSpecificationSchema checks shape, uniqueness and canonical ordering. validateApplicationDocumentPublicationSpecification additionally checks canonical language tags and jurisdiction references through the shared jurisdiction validator. Candidate jurisdiction validation uses that same vocabulary, so aliases and invalid regional shapes cannot acquire a second identity at another boundary.

Keep publication authority explicit

The declaration states intended variants. Content creation, review and publishing are distinct activities; a valid variant does not itself release a notice. The intended delivery contract uses only the declared combinations and the corresponding reviewed content. Preserve that pairing in the application’s publication process, and record audience changes deliberately in version control. The code above is the declaration contract, not a document-serving endpoint.

Carry the method into application work

Build assurance into the work of creating the application Tool

Assurance work belongs alongside product decisions and implementation. Wildo’s playbooks connect research and evaluation to the application’s facts, then use those facts to prepare governed documents.

Each step has a specific job and output. Research supplies material; evaluation decides whether it is sufficient; fact derivation reads the product; drafting turns declared facts into an explanation.

Example: Prepare a notice from the product’s own declarations

The fact step reads resource and operator declarations. The document step receives those facts and the document blueprint. It can improve the explanation without inventing a retention rule or deciding a legal question.

Research, evaluation, fact derivation and document drafting form distinct connected stages.
For engineers
Derive facts before drafting clauses

The actual refinement.document-facts playbook requests these context sources and declares its own execution policy:

method: {
  contextSources: [MetaWorkflow_ContextSource.COMPLIANCE_PRIMARY_FACTS, MetaWorkflow_ContextSource.DOCUMENT_FACT_OBLIGATIONS],
  skillRefs: ['journey-status'],
},
executor: MetaWorkflow_Executor.COMPANION_AGENT,
approval: MetaWorkflow_ApprovalPolicy.APPROVE_RESULT,
budget: { maxRuns: 3 },
onFailure: MetaWorkflow_FailurePolicy.KEEP_AND_FLAG,

Its output is the document-facts specification family. Vision and requirements explain purpose; resource declarations supply mechanism facts. The companion assembles those inputs and the fact schema constrains the proposed artifact.

Keep the document writer on declared facts

refinement.compliance-document-candidate receives document-facts and requests document_blueprint. Its brief-adequacy mechanism explicitly excludes raw compliance_primary_facts: those belong to the preceding derivation stage. Otherwise the writer could cite behavior that never became a declared document fact.

Let research and evaluation answer different questions

top_down.compliance-research gathers source material. top_down.compliance-evidence-adequacy returns a verdict and specific missing information for the posture stage; it does not author a document. This makes a further research round targeted rather than a repeat of the same broad request.

The intended workflow prioritizes derivation from code, configuration and evidence, using judgment for interpretation and declared operator choices where necessary. Playbook availability defines the work that can be orchestrated; it does not establish that a particular application has completed the programme or undergone assessment.

Bring the framework’s knowledge into the application Tool

Application work needs more than API names. Wildo packages the framework’s authoring knowledge and assurance definitions so the application’s tools can use the same contracts the framework defines.

The package brings references, methods and control expectations together. A generated reference explains the framework; evidence from the application must still demonstrate its own configuration and operation.

Example: Keep a coding agent and a reviewer on the same basis

After a framework update, regenerated knowledge carries the relevant source references and assurance basis into application tooling. Both can identify the version they are using instead of relying on remembered behavior.

Framework contracts and guidance form a knowledge package for builders and reviewers.
For engineers
Use the framework generation command

From a configured Wildo development environment, wildo fwk generate-docs resolves the framework repository and calls FrameworkKnowledgeGenerationService.generate. It reports its output directory, extracted topics and registry files. The generator discovers source anchors and authored knowledge, renders references and packages playbooks and assurance bases.

Preserve the basis identity

This source excerpt shows assurance bases being materialized and registered. A duplicate identity is rejected rather than silently replacing the earlier definition.

const assuranceAuthorityBasisRegistryEntries: Record<string, AssuranceAuthorityBasisRegistryEntryShape> = {};
for (const basisFile of FrameworkKnowledgeGenerationService.discoverAuthoredAssuranceAuthorityBases(frameworkRoot)) {
  const entry = FrameworkKnowledgeGenerationService.materializeAssuranceAuthorityBasis(
    basisFile,
    assuranceAuthorityBasesDirectory,
    frameworkRoot,
    generatedAt,
  );
  const identity = `${entry.ref}@${entry.version}`;
  if (assuranceAuthorityBasisRegistryEntries[identity]) throw new Error(`Duplicate authored assurance-authority basis identity "${identity}".`);
  assuranceAuthorityBasisRegistryEntries[identity] = entry;
}

The resulting payload includes the portable basis and its source mapping. Topic and source-tag registries point back to the extracted reference material; authored skills explain how an application uses it. The output is generated knowledge, so changes belong in the source or authored definition before regeneration.

Keep generated knowledge distinct from operational evidence

A source excerpt can establish an API contract. A packaged assurance basis can establish what an objective demands. Neither demonstrates that a deployed application performed the action successfully. Link operating claims to the appropriate event, execution or review record and its version. The separate knowledge audit checks package/reference coherence; it is not an application assurance assessment.

Give each process the access its work needs

The backend and separately declared background services each need their own connections and credentials. Wildo carries those declared needs into process configuration and network access, keeping the different parts aligned.

Keep configuration in source control and secret values in the appropriate environment or secret store. The framework handles the generated projections; operators control their distribution and lifecycle.

A separate secret store supplies each runtime with its own credentials and environment.

One declared purpose, consistent access

Provision without accidental rotation

A shared material registry fills missing framework credentials and keeps deliberate rotation separate from ordinary synchronization.

Narrow what each runtime receives

Runtime declarations shape grants, delivered secret material and service reachability, including an explicit empty-access result.

Keep deployment inputs coherent

Per-process environments and CI secret reconstruction derive from their canonical inputs, preserving key material across different readers.

Example: Run a read-only background task

A minion declares read access to todos and no write access. Its scoped credentials and generated environment follow that declaration; it does not receive the backend’s broad credential merely because one of its own grants is absent.

For engineers

Author runtime authority before generating artifacts

A minion is a separately declared background service with tick-based execution. This selected entry belongs inside Wonder Todos’ wildo.saas.config.ts; its independent platform-access settings are omitted:

minions: {
  marketingScrapper: {
    path: './minions/marketing-scrapper',
    resourceAccess: {
      read: ['todos'],
      write: [],
    },
  },
},

Run wildo config sync after editing the declaration, then inspect the minion’s generated environment and network membership. A worker is a separate runtime category; it does not receive the minion’s backing-service set by default. Write permission does not imply read permission. Resource-service access, backing-service credentials and network topology consume the declaration through their own boundaries. Platform-access scopes and internet egress are separate choices; do not treat application-data access as permission for either.

Preserve the chain across provisioning and delivery

StageWhat the framework derives
Managed material registryPresence, provisioning and rotation of framework-owned credential groups
Runtime principalWhich application or background runtime is presenting a credential
Provider provisioningThe scoped database, queue, cache or storage material
Authenticated fetchRemoval of broader material before the principal’s own values are overlaid
Environment generationThe raw values and encoding needed by that process’s reader
Network projectionReachability corresponding to the declared backing-service access

The application backend owns the base application material. Other principals do not inherit that material through a missing section. A short-lived object-storage session is scoped using the platform-issued application identifier; it is a different contract from database ownership.

Keep secret operations explicit

Use synchronization to regenerate derived configuration and provision missing material in file-backed environments. Rotation is a separate choice that must reach every affected signer, verifier or backing service. CI reads persisted secret-store values rather than creating credentials for the lifetime of a runner.

A running process keeps the environment it loaded until restart or rollout. Protect generated secret-bearing artifacts and keep source configuration separate from those outputs. Platform services access application configuration through a signed-token route; application runtimes present their own platform secret. Retrieval of a platform service’s own credentials uses a shared platform-secret boundary; the service-provisioning guide explains that distinction. Endpoint authorization and tenant access remain separate decisions.

Prepare trusted inputs

Fill missing framework credentials without replacing working ones Mechanism

Wildo declares the credential material it manages in one registry. Configuration synchronization can provision missing material while keeping already-provisioned credentials, so an existing environment can acquire what a new framework capability needs.

Example: Provision missing secrets without replacing working ones

An environment gains a new signing-key material during synchronization. The keys already used by its running services remain in place; deliberate rotation follows a separate action.

Existing database and API keys remain in place while a missing framework signing key is provisioned.
For engineers

wildo config sync invokes provisioning for file-backed environments. Each registry material declares its identity keys, generated secret keys, purpose, presence check and provisioning/rotation functions.

The provisioning loop checks each material before and after minting:

Selected source from framework-managed-secrets.ts:

export function provisionFrameworkManagedSecrets(
  secrets: PlatformEnvSecretsDraft,
): FrameworkManagedSecretsProvisioningResult {
  const mintedRefs: FrameworkManagedSecretMaterialRef[] = [];
  const mintedKeys: string[] = [];

  for (const material of FRAMEWORK_MANAGED_SECRET_MATERIALS) {
    if (material.isProvisioned(secrets)) continue;

    material.provision(secrets);

    if (!material.isProvisioned(secrets)) {
      throw new Error(
        `Framework-managed secret material '${material.ref}' did not satisfy its own provisioning check. `
        + 'This is a registry authoring bug in framework-managed-secrets.ts, not an environment problem.',
      );
    }

    mintedRefs.push(material.ref);
    mintedKeys.push(...material.identityKeys, ...material.secretKeys);
  }

  return { secrets: secrets as PlatformEnvSecretsJson, mintedRefs, mintedKeys };
}

The selected source returns the updated structured secrets object together with the material and key names it created. Operational reporting can use those names without displaying credential values. A material that fails its own presence check aborts provisioning instead of saving a falsely complete environment.

Keep backfill distinct from rotation

Normal synchronization provisions missing materials; --rotate-secrets is the explicit rotation choice. The general overwrite flag does not imply rotation. Rotation must be coordinated with the deployed services and published verification keys that use the changed material.

The registry groups keys that belong together. A complete existing material is preserved; repairing a partially missing material follows that material’s own provisioning function. Do not remove one key from a running credential pair as a way to request routine synchronization.

Keep operator choices with the operator

The registry can generate an administrator password when one was not supplied, but it does not invent the administrator’s identity. Vendor credentials and other operator-authored values remain inputs. In a fileless CI lane, the framework reads persisted secret-store material rather than minting new credentials that disappear with the runner.

Deploy with secrets outside source control Mechanism

Production secret values can stay in the deployment’s secret store while the repository holds configuration. Wildo reconstructs the structured secret inputs from the runner’s environment and uses them to generate the runtime artifacts.

Example: Deploy with secrets outside source control

A deployment workflow receives a provider credential from its secret store. The value reaches the runtime that needs it without being written into the application’s source files.

Repository configuration and a separate secret store provide deployment inputs to the runner.
For engineers

The CI adapter reads WILDO_SECRET_* variables and validates the reconstructed object with the same schema as file-backed environments. Provider variables use WILDO_SECRET_PROVIDER_ENV_<ENV_VAR_NAME>, retaining their declared runtime name.

Generate the workflow’s variable list from the configured application workspace:

wildo config sync --env=production --domain cicd

Inspect the generated deployment workflow’s env block. It derives required and optional inputs from the secret schema and the selected providers; do not copy a fixed list from another environment.

For example, suppose a configured provider declares EXAMPLE_API_KEY as its runtime secret. The corresponding workflow fragment is:

- name: Generate deployment artifacts
  env:
    WILDO_SECRET_PROVIDER_ENV_EXAMPLE_API_KEY: ${{ secrets.WILDO_SECRET_PROVIDER_ENV_EXAMPLE_API_KEY }}
  run: npx wildo config sync --ci --env=production --domain config

This is a placeholder-only example of one entry, not the complete generated workflow. Store the real value under that secret-store name; never replace the expression with a literal credential.

BoundaryName or result
Deployment secret storeWILDO_SECRET_PROVIDER_ENV_EXAMPLE_API_KEY
Runner environmentThe same name, populated only for the generation step
Reconstructed secret inputproviderEnv.EXAMPLE_API_KEY
Configured consuming runtimeProvider credential output under its declared runtime name

The adapter validates the reconstructed inputs. Required provider inputs must be supplied; optional inputs are identified separately by the generated list. CI consumes persisted material rather than minting temporary replacements. Generated Kubernetes artifacts are written under .wildo-saas/deploy/production/k8s/ for the deployment step to consume.

Preserve key material across the boundary

The adapter normalizes escaped newlines for key and certificate variables before use. Store the actual material in the deployment’s secret manager and expose it only to the runner steps that need it. Do not replace missing persisted keys by generating fresh ones during an ephemeral deployment.

Separate source hygiene from secret operations

The framework consumes the values; the secret store owns access, rotation and expiry policy. The runner holds them in memory and may render secrets-bearing deployment artifacts, which require their own access and cleanup policy. Local file-backed environments remain a separate supported workflow; their secrets are authored inputs, not disposable generated files.

Scope each runtime

Give each runtime credentials for its own work Mechanism

Wildo provisions application credentials and narrows background-runtime material to declared access. Database, queue, cache and storage access can follow the runtime’s responsibilities instead of inheriting a shared platform credential.

Example: Limit credentials to their runtime purpose

A minion allowed to read todos receives its scoped material. It does not inherit the backend’s database credential when a narrower credential is absent.

The backend and a background runtime receive different credentials, with scoped table, queue and temporary storage access.
For engineers

The application backend uses the application’s provisioned material. Additional runtime principals derive their grants from their declarations. A minion’s resourceAccess.read and write lists govern application data; platform-access scopes are a separate axis.

Database ownership and a scoped table grant use distinct provider operations. Scoped synchronization replaces the previous authority so removing a declared resource can remove the grant instead of leaving an accumulating permission set.

Declare read and write separately

Inside the minion’s entry in wildo.saas.config.ts, an illustrative editing task would declare:

resourceAccess: {
  read: ['todos'],
  write: ['todos'],
},
Intended workDeclaration and effect
Inspect todosPut todos in read; leave write empty
Read, validate and update todosPut todos in both lists
Write without any readPut it only in write; reads still refuse, including reads needed by validation or read-modify-write logic

These identifiers name resource types, not database collection names. User-relative self-resource doors are rejected by this authoring contract. Write permission deliberately does not imply read permission.

Synchronize the changed configuration and inspect the provisioned principal’s grants. Removing a resource must remove the corresponding authority, while a read-only declaration must still refuse a write. The neighboring runtime-topology guide shows the enclosing minion registration. Storage sessions remain scoped to the application’s prefix; a database read-only grant does not also make object storage read-only.

Confine delivery as well as provisioning

The authenticated configuration fetch resolves the presenting principal. Before overlaying a non-backend principal’s material, the manager removes every path that a provider can mint per principal:

Selected source from apps-to-manager.apps-manager.platform.controller.ts:

const isApplicationBackend = principal.kind === ApplicationRuntimePrincipalKind.APPLICATION_BACKEND;

const projected: Record<string, unknown> = structuredClone(rest);
if (!isApplicationBackend) {
  for (const path of collectPerPrincipalSecretBlobPaths()) {
    AppsToPlatformAppsManagerPlatformController.deleteSecretBlobPath(projected, path);
  }
}

if (!section) {
  return projected;
}

// Overlay the principal's own material, by the same JSON paths the provider modules mint into.
for (const [path, value] of Object.entries(section.secretBlobValues)) {
  AppsToPlatformAppsManagerPlatformController.assignSecretBlobPath(projected, path, value);
}
return projected;

This selected section is from the controller’s credential projection. Stripping occurs even when the principal has no credential section. Otherwise an absent narrow grant could accidentally leave the broad application value in place. The application backend is explicitly distinguished by principal kind because that base material is its own.

Match storage access to the application identity

Object storage uses short-lived sessions with a policy tied to the platform-issued application identifier and storage prefix. Bucket provisioning remains a platform operation; a prefix-confined session does not need permission to create the bucket.

Review the resulting principal grants and delivered material when changing a runtime declaration. Credential generation, network reachability and resource-service permissions reinforce one another, but they are distinct controls with distinct consumers.

Connect each runtime to the services it needs Guarantee

Wildo derives backing-service configuration and network membership from a runtime’s declared purpose. A background runtime can receive the services it needs without automatically receiving the backend’s full data access.

Example: Connect each runtime to what it needs

A read-only background task works with todos. Its declaration gives it the corresponding data access, while a task with no application-data declaration does not acquire database access by default.

Backend and background runtimes have different declared paths to database, queue and cache services.
For engineers

Wonder Todos selects read-only application data for its marketing-scrapper minion:

Selected source from wildo.saas.config.ts:

minions: {
  marketingScrapper: {
    path: './minions/marketing-scrapper',
    resourceAccess: {
      read: ['todos'],
      write: [],
    },
  },
},

This is a selected minions entry inside the existing wildo.saas.config.ts declaration, not a standalone configuration file. A minion is a separately declared background service with tick-based execution. The example omits its independent platform-access settings. A worker is a different runtime category; do not use the names interchangeably or assume a worker is the backend’s queue consumer.

After editing the declaration, run wildo config sync from the configured application workspace. Inspect the environment generated for ./minions/marketing-scrapper and its deployment network membership. Queue, cache and telemetry are part of the minion service set; database and object-storage topology follow the presence of resourceAccess. Inspect names and grants without printing credential values.

The presence of resourceAccess activates the data-service topology. Omitting it excludes those services; an explicit { read: [], write: [] } still activates the topology but grants no resources through these lists. Reachability and resource grants are separate decisions.

Derive addresses and reachability together

resolveRuntimeBackingServiceAccess evaluates the runtime principal and declaration. The environment builder intersects that access with provisioned services; resolveRuntimeNetworkMembership uses the same result for network membership.

RuntimeBacking-service interpretation
Application backendThe application’s provisioned service set
MinionQueue, cache and telemetry, plus data services when declared
WorkerNo application backing-service capability by default
Missing or unknown principalNo granted capability

Serving HTTP is a deployment choice. Internet egress is a separate explicit declaration. A connection address does not select the application’s database engine, and network reachability does not replace operation-level permissions.

For a new runtime, inspect the generated environment and network projection together. They should express the same declared access instead of compensating for one another.

Deliver and verify

Give each process one consistent environment Guarantee

Wildo turns authored configuration and scoped secrets into the environment each process reads. A backend, frontend or background runtime receives its own generated projection, so configuration does not depend on hand-maintained copies of the same value.

Example: Give each process one consistent environment

A multiline verification key is authored once and encoded for the environment reader used by its service. The generated runtime receives the complete key rather than a truncated first line.

Configuration produces distinct environment files consumed by backend and background processes.
For engineers

Author application and infrastructure choices in their configuration files. Use configuration synchronization to regenerate derived environments; edit the source value instead of patching a generated .env.

Builders return raw values. The local dotenv writer encodes them exactly once for its reader:

Selected source from env-configuration-generator.service.ts:

function writeEnvFile(filePath: string, env: Record<string, string>): void {
  const dir = dirname(filePath);
  if (!existsSync(dir)) {
    mkdirSync(dir, { recursive: true });
  }

  const content = Object.entries(env)
    .map(([key, value]) => `${key}=${encodeDotenvValue(key, value)}`)
    .join('\n');

  writeFileSync(filePath, GENERATED_FILE_HEADER + '\n\n' + content + '\n');
}

For example, take this harmless raw value (two lines, not a real key):

PUBLIC-LINE-ONE
PUBLIC-LINE-TWO

The Node dotenv sink writes one quoted entry with an escaped newline:

EXAMPLE_PUBLIC_MATERIAL="PUBLIC-LINE-ONE\nPUBLIC-LINE-TWO"

The dotenv reader reconstructs PUBLIC-LINE-ONE followed by a newline and PUBLIC-LINE-TWO. The provider passes the original two-line value; it does not add the quotes or the escape sequence itself.

Compose and Kubernetes have their own sink encoders. A value safe for one format is not necessarily correctly escaped for another. This is why providers and runtime builders should supply the raw key or JSON value, not a pre-quoted string.

Keep runtime projections separate

The generator creates environments per process kind. Kubernetes runtime environment Secrets and provider credential Secrets have derived names shared with the workload manifests. A runtime without provider contributions does not need a provider-secret mount; it still needs its required runtime environment.

ConsumerGenerated inputWhen a change takes effect
Node backend or declared background processIts own .envRestart with the regenerated environment
FrontendIts generated .env and companion .env.exampleRestart its development process or produce the new frontend deployment
Kubernetes workloadRequired runtime environment Secret and applicable provider SecretApply artifacts and roll out the consuming pods

Generation changes an artifact, not the memory of an already-running process. Restart or roll out the affected runtime so it reads the new projection. Protect local environment files and deployment artifacts as secrets-bearing material; encoding a Kubernetes Secret is not an at-rest encryption policy.

Verify signed service calls Mechanism

On the signed application-configuration route, the manager verifies a platform service against its published public key. The issuer and intended recipient are checked alongside the signature.

Example: Give platform services their own identity

The scheduler retrieves managed application configuration from the platform manager with its signed token. The manager verifies the claimed service, intended recipient and signature before accepting the request.

Services retain separate private keys and exchange signed messages using public verification material.
For engineers

Platform initialization provisions each service’s keypair and publishes the matching public key in platform configuration. The private key belongs to the signing service’s environment. The framework-managed registry makes missing signing materials part of synchronization rather than a manual key-copy task.

On receipt, the manager checks that the token issuer matches the service identity in the request, resolves that service’s published key and verifies the token:

Selected source from apps-to-manager.apps-manager.platform.controller.ts:

await this.jwtService.verifyRaw(token, publicKey, {
  // Validate issuer matches the serviceId
  issuer: jwtServiceId,
  // Audience should be apps-manager for platform-to-platform requests
  audience: PlatformApplicationType.PLATFORM_APPS_MANAGER,
});

This selected verification call is from apps-to-manager.apps-manager.platform.controller.ts. Its surrounding path refuses an unrecognized service or missing public key. The published configuration is the verification authority; it does not silently fall back to a second environment key.

The platform-service credential retrieval route is different: it authenticates a shared platform secret and looks up the supplied registered service ID. Do not extend the signed route’s caller-to-key binding to that shared-secret route.

Keep application authentication separate

An application runtime presents its platform secret through the application door. The manager resolves the presenting runtime principal from that credential rather than trusting a principal name supplied by the caller.

A service token proves service identity and audience. The endpoint still decides what that service may do; it is not a user session or a general authorization grant.

Coordinate a key change

When rotating, distribute the private half to the signing service and publish the corresponding public half for verifiers. A mismatch is an authentication/configuration failure with a republishing remedy, not a reason to weaken verification. In local environments, wildo local init republishes platform configuration from the secret store.

Trust has a foundation you can inspect.

Access decisions, personal-data treatment and important actions belong to the application’s everyday behavior. Their evidence should remain connected to the commitments made about that behavior.

Wildo brings those connections into the product’s design, from resource declarations to audit history and governed documents. Your operating choices give them context; evidence gives reviewers something concrete to evaluate.

Building a B2B product or an internal tool?

Wildo is not self-service yet. Tell us what you have in mind and we will say plainly whether it fits, and what happens next.