SIGN IN SIGN UP

feat(core)!: rebuild the domainkit core on the 0.9 API sketch (#86)

## Summary

Rebuilds the `domainkit` core on the approved 0.9 API sketch: 19 concept
modules (`Service` and `Interface` for services, `Model` for values),
one `DomainKit.Error` carrying a `Reason` union, Principal-scoped
`Storage` with a memory implementation and conformance suite, durable
plan/approve/apply/reject and cleanup attempts, declarative providers
(Cloudflare and Vercel rebuilt on `Provider.make`), built-in AES-GCM
custody, a resolver pool with persisted readiness, connection discovery,
and a `domainkit/testing` entry hosts can run against the seam. The
Promise root, `domainkit/server`, and the dead stores are gone; server
and client return in the next layer.

## Usage

```ts
import { Effect, Match } from "effect";
import { DnsRecord, DomainKit, Principal, Provision, Verify } from "domainkit";
import { Testing } from "domainkit/testing";

const program = Effect.gen(function* () {
  const plan = yield* Provision.plan({ domain: "app.example.com", requirements });
  const approval = yield* Provision.approve(plan);
  const receipt = yield* Provision.apply(approval); // complete | partial, safe to retry
  const readiness = yield* Verify.observe({ domain: "app.example.com" });
  return { receipt, ready: readiness.overall === "ready", nextCheckAt: readiness.nextCheckAt };
}).pipe(
  Effect.catchTag("DomainKitError", (error) =>
    Match.value(error.reason).pipe(
      Match.tag("Conflict", ({ operations }) => Effect.fail(`Fix ${operations.length} conflicting record(s) first`)),
      Match.tag("Stale", () => Effect.fail("Provider changed under us; plan again")),
      Match.orElse(() => Effect.fail(error.message)),
    ),
  ),
  Effect.provideService(Principal.Service, { ownerId: "org_42", actorId: "user_7" }),
  Effect.provide(DomainKit.layerMemory({ providers: [Testing.provider({ zones: ["example.com"] })] })),
);
```

Host wiring: `DomainKit.layer({ providers
}).pipe(Layer.provide([YourStorage, Custody.layerConfig()]))`;
`Principal` per request.

## Lifecycle

```text
Provision.plan   -> attempt planned   (requirements must sit inside the attached domain; digest over operations, labels excluded)
Provision.approve-> attempt approved  (Conflict unless allowPartial; same approval on replay)
Provision.reject -> attempt rejected  (terminal; actor, reason, time; approve/apply then Stale)
Provision.apply  -> claim lease -> re-plan (Stale) -> write approved creates -> receipt
                    first write fails: attempt failed, error rethrown, retry re-claims
                    later write fails: Applied / Failed / Skipped(not-attempted), receipt partial
                    replay: stored receipt
Cleanup.plan     -> receipt records read back; missing or changed -> Conflict, else Delete
```

## API notes beyond the sketch

| Area | Decision |
| --- | --- |
| Credentials | Storage stores only `Credential { ciphertext, expiresAt,
rotatedAt }`; `Connect` seals and opens through `Custody`, so every
Storage implementation gets encryption for free |
| Storage | `attempts.byApproval`, `attempts.reject`,
`continuations.get`, `Attempt.failure`, `Attempt.rejection`; `Readiness`
keyed by `domain` with `attachmentId: string \| null`, per-requirement
`record` + `evidence`, `host`, `pendingSince`; `AsyncInterface` splits
two-phase revocation and locking into plain steps |
| Plan | `Delete` operation (cleanup), `Conflict.reason` gains
`missing`, `Receipt.Skipped.reason` gains `not-attempted`, `Plan.kind` |
| Provider | `Dns.list` returns `{ record, providerRecordId }`;
`Dns.create` returns a non-null id; `complete` receives every callback
`params`; `IssuedCredential.expiresAt: DateTime.Utc \| null`;
`Target.nameservers` |
| Context | `Authorization.context` and `Attachment.target.context` are
`{ version, value }` envelopes tagged with
`Provider.Definition.contextVersion`; `migrateContext` upgrades an older
version, anything else fails `Unsupported` |
| Token fields | `TokenAuth.fields` schema + `Provider.tokenAuth`;
`Connect.Method.token(values)` (a lone string is `{ token }`);
`Snapshot.providers[].methods[]` = `{ kind, label, docsUrl, fields: [{
name, required, secret }] \| null }` |
| Discovery | `Connect.discover(domain)` = `Resolved { connectionId,
target } \| SelectionRequired { candidates } \| NotFound { nameservers
}` |
| Errors | `Unsupported` (501) and `ProviderConflict` (409) reasons; 403
-> `Forbidden`, Vercel 404 -> `NotFound`, Cloudflare 81056-81058 ->
`ProviderConflict` |
| Boundary | `Provision.plan`/`Cleanup.plan` reject requirements outside
the attached domain (`InvalidInput`) |
| Observe-only | `Verify.observe` works with public DNS alone when a
domain has no attachment or its session cannot be built |
| Sessions | `Connect.session` re-checks the attached zone is still
reachable (`NotFound`); refresh re-reads the row under the lock and
revokes an orphaned fresh credential |
| Callback | `Connect.complete` validates, exchanges, persists, then
spends the continuation under a per-continuation lock |
| Unions | `Provider.Resolution`, `Resolver.Outcome`,
`Connect.Method/Started/Selection/Discovery` are `Data.taggedEnum`
constructors |
| Async | `Provider.fromAsync(definition)` mirrors
`Storage.layerFromAsync` |
| DoH | message id, question class, and TTL checks;
`Resolver.resolve(name, type, { signal })` |
| DomainKit | `Options.resolver?: Layer<Resolver.Service>` to swap the
DoH pool in tests |
| Naming | Services export `Service` (tag) and `Interface` (shape):
`Storage.Service`, `Principal.Interface`, `Storage.AsyncInterface`;
values export `Model`: `Plan.Model`, `DnsRecord.Model`,
`DomainName.Model`; the error is `DomainKit.Error` (`DomainKit.isError`,
`DomainKit.ErrorCategory`) with reasons under `Reason.*` and the union
`Reason.Model` |

## Validation

- `bun run --filter domainkit release:check` (typecheck, 101 tests,
build, examples typecheck, 8 artifact tests incl. Node/Bun/workerd
packed consumer)
- `bun examples/effect/quickstart.ts` prints a complete receipt and
`ready: true`
- `bun run lint && bun run lint:rules && bun run format:check`
- Live provider tests (`tests/live/*`) are user-owned and were not run

The root `release:check` gates lint, format, and the `domainkit` package
only; the storage and React layers above this PR add
`@domainkit/capsuledb` and `@domainkit/react` back as they rewrite them.

## Follow-up scope

- Storage has no per-write progress method: a crash mid-apply after some
writes leaves those records without a receipt until the host re-plans
(ADR 0003). Candidate for the storage layer.
- Package version stays `0.8.0`; the release lane bumps it through
Tegami.
S
Saatvik Arya committed
605eb99c266030bdfed75073ca8c7d09d6ade08d
Parent: eb727b3
Committed by GitHub <noreply@github.com> on 9/3/2026, 6:54:02 PM