diff --git a/.agents/review/decisions.md b/.agents/review/decisions.md deleted file mode 100644 index d451492..0000000 --- a/.agents/review/decisions.md +++ /dev/null @@ -1,59 +0,0 @@ -# Review decisions registry - -Durable, already-litigated review decisions. How reviewers must treat entries and when orchestrators append them is defined in the `review` and `review-fix` skills. - -Entry format: heading `### D-NNN (date, status) — title`, where status is `decided` or `open`, followed by the decision and its rationale in prose. Entries are never edited silently; superseding an entry means a new entry that references the old id. - -## Entries - -### D-001 (2026-08-23, decided) — Auth tables keep better-auth's naive timestamp shape - -The four better-auth tables declare their timestamp columns as `timestamp` without time zone with `DEFAULT now()`, so a value produced by the default records the database server's wall clock instead of an absolute instant. This is accepted. better-auth's adapter always supplies these values explicitly on every write it performs, so the column defaults never fire in practice, and deviating from the shape better-auth generates risks adapter drift on upgrades. Revisit only if a write path to these tables appears that does not go through the adapter. - -### D-002 (2026-08-23, decided) — Root test:a11y script is required by the standards structure check - -The review flagged the root `test:a11y` Turbo alias as duplicating what the root `check` script already runs. The alias stays: the standards structure gate hard-fails without it, observed while scaffolding this repository. Revisit only if the standards template drops the requirement. - -### D-003 (2026-08-23, decided) — Word counts are not database-constrained against the markdown they count - -The `entry` table stores `journal_word_count` and `scripture_word_count` as plain numbers next to the markdown they describe, and no database constraint ties one to the other, so a wrong number would make the archive heatmap show a day as heavier or lighter than it was. This is accepted. A CHECK constraint can only compare values already in the row, and counting words in markdown means tokenising prose, which is not something SQL can express as a constraint. The app write path is the only writer to `entry` and therefore owns the invariant: it computes both counts from the same markdown it saves in the same statement. Tests for that write path will pin the invariant when the write path lands. Revisit if a second write path to `entry` appears — an importer, a migration backfill, or manual SQL — because the invariant then has no single owner. - -### D-004 (2026-08-23, decided) — The server-function guard test passes with nothing to guard yet - -`apps/web/src/shared/auth/sensitive-server-fns.test.ts` scans the app's source for server functions — functions the browser calls to run code on the server — and fails when one does not carry the `sessionRequired` middleware that turns away a caller with no session. The app has one server function today, `hasAuthorizedSessionFn`, and it is on the allowlist of surfaces that must answer while signed out, so the guard check currently runs against an empty list and would pass whatever the code looked like. That is accepted and deliberate: the test is a ratchet, placed so the first server function a feature adds cannot land unguarded without the suite going red. The part of it that is not vacuous today is the allowlist assertion, which pins the exact set of surfaces reachable without a session — that one server function, plus the two route files with request handlers, the liveness probe and better-auth's catch-all — and fails both when a new public surface appears and when a listed one disappears. Revisit if the scan approach changes. - -### D-005 (2026-08-23, decided) — The guard scan's ratchet stands under the chain-walking rewrite - -D-004 closed with "revisit if the scan approach changes", and the approach changed: the scan no longer slices marker-to-next-same-marker text spans but walks each marker's call chain directly, resolves `createServerFn` and `createFileRoute` from any import specifier, accepts `sessionRequired` only from the resolved real middleware module, and keeps its allowlists per function and per HTTP verb instead of per file. D-004's substantive claim is re-affirmed under the new approach. The production guard check remains vacuous — the one real server function is the allowlisted `hasAuthorizedSessionFn` — but the guard logic itself is now non-vacuously proven against inline fixtures covering guarded, unguarded, trailing-middleware, re-exported-marker, and decoy-guard shapes, so a scan regression fails the suite even while the production list is empty. Revisit under the same condition as D-004. - -### D-006 (2026-08-23, decided) — Re-entry guards keep the explicit RefObject annotation - -`login.tsx` and `_app.tsx` annotate their re-entry guards as `const started: RefObject = useRef(false)`. A review proposed dropping the annotation as redundant because React 19's types already infer `RefObject`. Removal keeps `tsc --noEmit` green, but Biome then narrows the ref's `current` to the literal `false` and fails `lint/suspicious/noUnnecessaryConditions` on the `if (started.current)` checks in both files, observed directly during the final repair. The annotation is load-bearing for lint, not decoration. Revisit if Biome's narrowing changes or the guard pattern is replaced. - -### D-007 (2026-08-26, decided) — Current and hovered navigation links may share a rule that differs only in colour - -The navigation marks the current page with a hairline rule under the label in the primary green, and extends a `currentColor` rule under any link the pointer is over. While the pointer sits on a link that is not the current page, the two rules differ only in hue, which a review read as information carried by colour alone. This is accepted. A hover is a transient state the reader produces themselves and locates by their own pointer, so it is not information they have to recover from the page; the durable "you are here" state is carried by `aria-current="page"` for assistive technology and by a rule that is out at full width while every other link's is at zero width for everyone else. Nothing the reader must know is colour-only. Revisit if the current-page rule ever rests at the same width as a hovered one, because the hue would then be the only difference in a state that is not transient. - -### D-008 (2026-08-26, decided) — Navigation and the quiet controls are set as 12px letterspaced capitals - -The navigation links and the sign-out control are set in the design's eyebrow style — small letterspaced capitals — rather than in sentence-case body text as the scaffold had them. A review raised the drop in type size as a legibility concern. This is accepted: it is the vocabulary of the design David picked, where structure comes from typography rather than boxes, and eyebrows are how a section or a control announces itself. The accessibility bar is held separately and was measured: the touch targets clear WCAG 2.2 SC 2.5.8 with target-size checks explicitly enabled, and the ink pairs clear 4.5:1 in the token audit. Revisit if a control ever depends on the eyebrow style to be recognised as interactive at all, since size then compounds a discoverability problem rather than standing alone as a type choice. - -### D-009 (2026-08-26, decided) — Shared class recipes are composed in modules, outside the Tailwind class sorter's reach - -The shared shape and control recipes are built as arrays of class fragments joined into one string, so the `className` attributes that consume them hold a variable rather than a literal and Biome's `useSortedClasses` cannot see or sort them. This is accepted. Which of two utilities setting the same property wins is decided by their emission order in the generated stylesheet, never by their order inside a class attribute, so sorting is a readability convention and not a correctness mechanism — and the recipes are already structured around that fact, keeping every state colour out of the shared base string precisely because attribute order cannot order it. Composing in a module is what stops one page drifting from another by retyping the same classes. Revisit if a Tailwind or Biome release makes attribute order semantically meaningful, or if a local `cn`-style helper is introduced for another reason, since the rule's `functions` option would then cover these call sites for free. - -### D-010 (2026-08-26, decided) — Forced-colors mode is not a supported rendering of the header's rules - -Windows High Contrast, which the browser exposes as forced-colors mode, replaces every author colour with a small palette the reader chose. The verification pass measured what that does to Postlude's header: both hairline rules there are painted as a background on a pseudo-element, and forced colors repaints a background to the page's own ground, so the current-page rule and the sign-out control's rule both vanish. The three eyebrow labels then look identical, and the only remaining "you are here" signal is the `aria-current="page"` a screen reader gets. This is accepted. Forced colors is a reader's own override of the author's palette, the rendering the design does control passes SC 1.4.1 and 1.4.11 as measured, and Postlude has exactly one reader, on Linux, whose environment has no forced-colors mode. Painting the rules as borders instead would survive it, and is the fix if this is ever revisited. Revisit if Postlude gains a reader beyond its one authorized account, or if forced-colors support becomes a stated requirement rather than an inherited platform behaviour. - -### D-011 (2026-08-26, decided) — The sign-out control may rest with the same hairline that marks the current page - -The sign-out control was changed to keep its rule out at rest, because a rule only a hover draws is a rule a touch device never sees. That put a second resting hairline in the header, 24px from the navigation's current-page rule, and the two are close enough in luminance that hue is most of what separates them. This is accepted. The current page is still recoverable without colour by reading inside the navigation, where the link you are not on rests at zero width, and the words themselves already separate two places from one action. D-007's own revisit condition — the current-page rule resting at the same width as a hovered one — is not what happened here. Revisit if a third ruled control joins the header, or if the navigation ever stops being the one place where a resting rule and a zero-width rule can be compared side by side. - -### D-012 (2026-08-27, decided) — The production guard scan is non-vacuous and resolves renamed factories - -This decision supersedes D-004 and D-005. The production check now has private journal server functions to inspect, including `searchJournalFn`, and `/search` has a private `POST` handler. The scan still pins the exact public and private lists, so a new unguarded function or request handler fails without an allowlist change. It resolves named framework-factory imports through renamed and multi-step local re-exports. An unresolved local re-export remains a candidate factory and therefore cannot hide a call from the allowlist. For route options, the scan reads the effective final `server`, `handlers`, and `middleware` properties after inline object spreads run in source order. An unresolved spread, computed property, alias it cannot prove, or otherwise ambiguous handler object becomes an unreadable unguarded handler. Only `sessionRequired` imported from `shared/auth/auth-middleware.ts` earns the guarded state. Revisit if a factory is wrapped in a local function instead of re-exported, route configuration becomes dynamic, or the framework changes these declaration shapes. - -### D-013 (2026-08-27, decided) — Native search POST data may remain in the local browser session - -Search query text must stay out of the URL, referrer headers, routine URL logs, and syncable navigation history. The hydrated page meets that boundary by keeping the query in component memory and sending it with `POST`. Without JavaScript, the browser performs a document-level `POST`. Chromium verification showed that reload can resubmit that body and Back can retain the response in the local browser session. This is accepted for Postlude's single authorized reader because removing the local-session trace while preserving a no-JavaScript result would require server-side one-time query state with its own storage and expiry rules. Revisit if the native POST is replaced, Postlude supports multiple readers or shared devices, or local browser-session retention enters the privacy threat model. diff --git a/.agents/skills/ci-pipelines/SKILL.md b/.agents/skills/ci-pipelines/SKILL.md index 87fbc40..e04e038 100644 --- a/.agents/skills/ci-pipelines/SKILL.md +++ b/.agents/skills/ci-pipelines/SKILL.md @@ -5,6 +5,8 @@ description: Use when changing continuous integration, GitHub Actions workflows, # CI pipelines +Canonical workflows use maintained major-version tags for external actions. + ## Billing shapes the job graph - Jobs bill per minute, rounded up, minimum one. Fold sub-minute checks into an existing job on the same trust level instead of giving them their own. diff --git a/.agents/skills/declarative-infra/SKILL.md b/.agents/skills/declarative-infra/SKILL.md index de34694..9a83e91 100644 --- a/.agents/skills/declarative-infra/SKILL.md +++ b/.agents/skills/declarative-infra/SKILL.md @@ -26,3 +26,5 @@ Before pushing, run `nix flake check`, build the host toplevel, run `tofu fmt -c - For SOPS, development environments, CI secrets, or provider credentials, read [Secrets bootstrap](references/secrets.md). - For preview environments, read [Pull request previews](references/pr-previews.md). They are the default for web-host adoption; omitting them requires a recorded decision in the host repository. - For image promotion into a dedicated infrastructure repository, read [Image promotion](references/image-promotion.md). + +- For third-party container updates whose publisher is outside the infrastructure owner's control, read [Vendor image promotion](references/vendor-image-promotion.md). diff --git a/.agents/skills/declarative-infra/references/bootstrap.md b/.agents/skills/declarative-infra/references/bootstrap.md index 7032147..2d733f1 100644 --- a/.agents/skills/declarative-infra/references/bootstrap.md +++ b/.agents/skills/declarative-infra/references/bootstrap.md @@ -146,6 +146,26 @@ in { email = acmeEmail; # required: ACME registration contact }; + systemd.services.podman-image-prune = { + description = "Remove unused container images older than seven days"; + serviceConfig = { + Type = "oneshot"; + Nice = 19; + IOSchedulingClass = "idle"; + }; + script = '' + ${pkgs.podman}/bin/podman image prune --all --force --filter until=168h + ''; + }; + systemd.timers.podman-image-prune = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "daily"; + Persistent = true; + RandomizedDelaySec = "30m"; + }; + }; + virtualisation.podman = { enable = true; defaultNetwork.settings.dns_enabled = true; # containers resolve each other by name @@ -162,6 +182,9 @@ Beyond the module: - Only Caddy publishes services; nothing else opens 80/443, and every additional firewall port is a documented decision in the host repo. - Pin container images by digest. +- Every Podman host needs image retention for production as well as previews. Daily image-only pruning of unused images created more than seven days ago is the default. Podman protects images referenced by any container, including stopped containers. Do not use `podman system prune`, volume pruning, or external-container removal as a substitute. Nix garbage collection and journal retention do not reclaim OCI images. +- The age filter uses the image creation timestamp, not its pull or last-use time. Retired images may need to be pulled again for rollback; keep registry access available. Where a deployment or preview controller protects staged or rollback images beyond container references, coordinate cleanup with that controller or exclude its labels and retain its own collector. A preview-only collector does not replace production retention. +- Verify the timer and a successful cleanup on each host after deployment. During a disk-full incident, check PostgreSQL recovery and every service sharing the filesystem after reclaiming unused images; never remove database files or volumes to make room. - A host running PostgreSQL also runs a local dump timer with retention (hourly `pg_dump --format=custom` into a `postgres`-owned directory is the norm); shape the unit however reads best. - A host running GitHub Actions jobs uses `services.github-runners.` with a SOPS-provided token, `programs.nix-ld.enable = true` plus `NIX_LD`/`NIX_LD_LIBRARY_PATH` in the runner environment so downloaded tooling executes, and systemd resource caps (`CPUQuota`, `MemoryHigh`/`MemoryMax`) so jobs cannot starve the host's services. @@ -267,7 +290,7 @@ A repo whose trusted CI builds and deploys NixOS system closures uses a private, A deploy that changes an app's image restarts `podman-.service` in place: the old container stops before the new one starts, and Caddy has no upstream for that app until the new container is up. That brief interruption is the accepted default for this profile. The health readback in `image-promotion.md` verifies that the deploy completed; it does not keep the old container serving through the switch. -Keep the window to container startup, not registry pull: after the deploy job's identity and registry-access checks and immediately before `nix run .#deploy-rs`, the workflow pre-pulls every gated image reference on the target over the deploy SSH connection. Set `REGISTRY_AUTH_FILE` and pass `--authfile` on every Podman registry command. Public entries use the root-owned empty `/run/containers/auth/anonymous.json`; private entries use only `/run/containers/auth/ghcr-private.json`, atomically created by the host's SOPS-backed `podman-ghcr-login` unit. Give every container unit the matching `REGISTRY_AUTH_FILE` too, so implicit pulls cannot fall back to ambient Podman or Docker auth. A private pre-pull always contacts the registry even when the digest is cached, so missing or expired credentials fail before activation; a public cached digest may skip its network pull after the drift detector has independently proved anonymous access. Pulling by digest is idempotent and additive, and any failed pre-pull fails the deploy before activation touches the running system. +Keep the window to container startup, not registry pull: after the deploy job's identity and registry-access checks and immediately before `nix run .#deploy-rs`, the workflow pre-pulls every gated image reference on the target over the deploy SSH connection. Set `REGISTRY_AUTH_FILE` and pass `--authfile` on every Podman registry command. Public entries use the root-owned empty `/run/containers/auth/anonymous.json`; private entries use only `/run/containers/auth/ghcr-private.json`, atomically created by the host's SOPS-backed `podman-ghcr-login` unit. Give every container unit the matching `REGISTRY_AUTH_FILE` too, so implicit pulls cannot fall back to ambient Podman or Docker auth. A private pre-pull always contacts the registry even when the digest is cached, so missing or expired credentials fail before activation; a public cached digest may skip its network pull after the same deployment has independently proved anonymous access to that exact digest. Pulling by digest is idempotent and additive, and any failed pre-pull fails the deploy before activation touches the running system. Private-image migration and container units require and order after `podman-ghcr-login.service`. Public-image units must not depend on that service. The SOPS secret restarts the oneshot when its decrypted value changes. The unit reads `registry.github_token` through stdin, writes a same-directory `0600` temporary auth file, and atomically renames it to the private path only after `podman login` succeeds. The complete credential, two-stage adoption, auth-file, and rotation contract is in `image-promotion.md#private-ghcr-host-access`. diff --git a/.agents/skills/declarative-infra/references/image-promotion-contracts.md b/.agents/skills/declarative-infra/references/image-promotion-contracts.md index 95ca4e2..a1a20e1 100644 --- a/.agents/skills/declarative-infra/references/image-promotion-contracts.md +++ b/.agents/skills/declarative-infra/references/image-promotion-contracts.md @@ -65,7 +65,7 @@ lifecycle: [announced, branch, open, merged, deploy-failed, completed, supersede ```yaml imagesPath: infra/images.json -metadataFields: [sourceRepository, sourceRef, sourceWorkflow, imageRepository, registryAccess, trackedTag, promotionLatencyMinutes] +metadataFields: [sourceRepository, sourceRef, sourceWorkflow, imageRepository, registryAccess] pinFields: [promotionEnabled, digest, promotedSourceSha] disabledPin: { promotionEnabled: false, digest: null, promotedSourceSha: null } operations: @@ -80,14 +80,14 @@ operations: ```yaml public: - detectorCredential: none - detectorProof: anonymously-readable + workflowCredential: none + workflowProof: anonymously-readable hostCredential: none hostAuthFile: /run/containers/auth/anonymous.json private: - detectorCredential: github-actions-token - detectorPermissions: { contents: read, packages: read } - detectorProof: exact-private-visibility-then-anonymous-denied-then-authenticated-readable + workflowCredential: github-actions-token + workflowPermissions: { contents: read, packages: read } + workflowProof: exact-private-visibility-then-anonymous-denied-then-authenticated-readable hostCredential: sops-classic-pat hostCredentialScopes: [read:packages] hostCredentialAuthority: all-packages-readable-by-token-owner @@ -99,25 +99,6 @@ private: forbiddenDesiredStateFields: [credential, secretPath, username, authFile] ``` - -```sh -set -euo pipefail -case "$REGISTRY_ACCESS" in - public) - resolve-anonymous-tag - ;; - private) - require-exact-private-visibility - reject-anonymous-readable - resolve-authenticated-tag - ;; - *) - printf 'unsupported registry access mode: %s\n' "$REGISTRY_ACCESS" >&2 - exit 1 - ;; -esac -``` - ```sh set -euo pipefail @@ -231,8 +212,13 @@ digest_hex=${DIGEST#sha256:} branch="image-bump/${APP}/${SOURCE_SHA:0:12}-${digest_hex:0:12}" prs=$(gh pr list --repo example/infra --state all --search "\"$marker\" in:body" --json number,body,state) pr=$(jq -er --arg marker "$marker" '[.[] | select(.state == "MERGED" and (.body | contains($marker)))] | if length == 1 then .[0].number else error("expected one merged promotion PR") end' <<<"$prs") -view=$(gh pr view "$pr" --repo example/infra --json state,mergeCommit,author,headRefName,headRepository,files,statusCheckRollup) -merge_sha=$(jq -er --arg branch "$branch" 'if .state == "MERGED" and .author.login == "promotion-bot[bot]" and .headRefName == $branch and .headRepository.nameWithOwner == "example/infra" and [.files[].path] == ["infra/images.json"] and ([.statusCheckRollup[] | select(.name == "trusted-promotion-provenance" and .conclusion == "SUCCESS")] | length) == 1 then .mergeCommit.oid else error("merged promotion PR is not trusted") end' <<<"$view") +view=$(gh pr view "$pr" --repo example/infra --json state,mergeCommit,author,headRefName,headRepository,headRefOid,files) +merge_sha=$(jq -er --arg branch "$branch" 'if .state == "MERGED" and .author.login == "promotion-bot[bot]" and .headRefName == $branch and .headRepository.nameWithOwner == "example/infra" and [.files[].path] == ["infra/images.json"] then .mergeCommit.oid else error("merged promotion PR is not trusted") end' <<<"$view") +head=$(jq -er '.headRefOid | select(test("^[0-9a-f]{40}$"))' <<<"$view") +checks=$(gh api "repos/example/infra/commits/$head/check-runs?check_name=trusted-promotion-provenance&filter=latest&per_page=100" --paginate --slurp) +jq -e --arg head "$head" '[.[].check_runs[]] | length == 1 and all(.[]; + .name == "trusted-promotion-provenance" and .head_sha == $head and + .app.slug == "github-actions" and .status == "completed" and .conclusion == "success")' <<<"$checks" >/dev/null encoded=$(gh api "repos/example/infra/contents/infra/images.json?ref=$merge_sha") images=$(jq -er '.content' <<<"$encoded" | base64 --decode) jq -er --arg app "$APP" --arg digest "$DIGEST" --arg sha "$SOURCE_SHA" 'select(.[$app].promotionEnabled == true and .[$app].digest == $digest and .[$app].promotedSourceSha == $sha) | true' <<<"$images" >/dev/null @@ -242,19 +228,3 @@ gh run watch "$run_id" --repo example/infra --exit-status result=$(gh run view "$run_id" --repo example/infra --json headSha,conclusion,jobs) jq -er --arg sha "$merge_sha" 'if .headSha == $sha and .conclusion == "success" and ([.jobs[] | select(.name == "deploy" and .conclusion == "success")] | length) == 1 and ([.jobs[] | select(.name == "deploy")] | length) == 1 then true else error("exact deploy did not complete successfully") end' <<<"$result" >/dev/null ``` - - -```sh -set -euo pipefail -window=0 -while :; do - initial_desired=$(read-desired-digest "$window" initial) - initial_observed=$(resolve-tracked-tag "$window" initial) - test "$initial_desired" != "$initial_observed" || exit 0 - wait-promotion-window "$window" - current_desired=$(read-desired-digest "$window" current) - current_observed=$(resolve-tracked-tag "$window" current) - if test "$current_desired" != "$initial_desired" || test "$current_observed" != "$initial_observed"; then window=$((window + 1)); continue; fi - exit 1 -done -``` diff --git a/.agents/skills/declarative-infra/references/image-promotion-groups.md b/.agents/skills/declarative-infra/references/image-promotion-groups.md new file mode 100644 index 0000000..06bbf4f --- /dev/null +++ b/.agents/skills/declarative-infra/references/image-promotion-groups.md @@ -0,0 +1,27 @@ +# Coordinated image releases + +Use a release group when images from one source repository share a release boundary, such as a UI and worker that share a database and API. Grouped promotion is required once the infrastructure owner declares that boundary. It is not required for unrelated applications. + +## Declaration and proof + +Keep group membership in a reviewed infrastructure-owned manifest alongside `images.json`. A group has one primary app and a nonempty set of unique members that includes that primary. Every member exists in `images.json`, belongs to at most one group, and has a distinct image repository. All members share the source repository, ref, authorized workflow, enablement state, and promoted source SHA. Validate the whole document before using any member. Membership and source metadata changes use the disabled metadata transition for the whole group. + +For example, `{"mail-ui":["mail-ui","mail-worker"]}` declares `mail-ui` as the announcement entrypoint. A companion cannot independently request promotion. The trusted writer reads membership from its trusted base, never from an announcement or candidate branch. + +One successful source push run builds all members from the same source commit. Its unique successful build job emits one immutable `IMAGE_PROMOTION_RECORD` per member. Announce only after the entire source workflow has completed successfully. The infrastructure verifier authenticates the configured workflow and run, normalizes its build log, and requires exactly the declared set of records. Every record must bind the same repository, ref, source SHA, and run ID to its configured image and a valid digest. Missing, duplicate, additional, or conflicting records reject the whole candidate. Prove registry access independently for every member. + +The primary announcement supplies the existing scalar payload. Companion digests come exclusively from the verified records. A primary identity is bound to the complete proven member-to-digest map. Repeated announcements may add successful run evidence only when that map is identical. A different companion digest is a conflicting release, even when the primary digest is unchanged; reject it and publish a new source commit. + +## Desired state and lifecycle + +The trusted writer updates every member's digest and source SHA in one infrastructure PR. An unchanged member digest is allowed, but its source SHA must still record the new coordinated release. The provenance gate compares the whole group against the exact proof and rejects partial transitions, unrelated app changes, or metadata edits. The PR changes only `images.json`. + +Apply duplicate detection, branch reuse, terminal supersession, current-main ancestry, and rollback to the whole group. Comparing only the primary pin cannot prove a duplicate. Reusing an existing branch requires its complete group map to match. A superseded group operation cannot be reopened through a companion announcement. A rollback selects and verifies the whole previous release as a new approved operation. + +## Deployment boundary + +A single PR prevents inconsistent desired pins; it does not make container replacement atomic. Choose and document a rollout that respects the application's compatibility boundary. If old and new processes cannot safely share the database or API, stop every group member before migration, run migrations from the selected release, and restart the complete group. Otherwise explicitly establish compatibility across the transition. Do not claim that grouped pins alone prevent temporarily mixed running versions. + +Pre-pull and verify all group images before stopping services. Migration failure stops the rollout and leaves completion failed. Do not restart an old member against a changed database without a separately approved recovery decision. + +Completion requires the exact infrastructure merge SHA and deploy attempt, matching image readback and health for every member, and successful shared migrations and infrastructure postconditions. A healthy UI with a failed or old worker is incomplete. Record that result on the coordinated PR with a link to the deployment evidence. diff --git a/.agents/skills/declarative-infra/references/image-promotion.md b/.agents/skills/declarative-infra/references/image-promotion.md index cffee1d..936e85c 100644 --- a/.agents/skills/declarative-infra/references/image-promotion.md +++ b/.agents/skills/declarative-infra/references/image-promotion.md @@ -2,13 +2,13 @@ When an app's infrastructure home is a dedicated infra repo, deployment freshness is automation-owned: the source repo announces every successful image build, and the home repo's trusted writer proposes the desired-state change. Never edit a live pin by hand or treat either PR merge as deployment completion. -**Completion invariant:** a source change is done only when the exact infra merge SHA has passed its fail-closed gate and every required target has returned a healthy readback of the expected digest. A failed or partial activation is incomplete; report it instead of attempting automatic cross-system rollback. +**Completion invariant:** an approved deployment is done only when the exact infra merge SHA has passed its fail-closed gate and every required target has returned a healthy readback of the expected digest. A failed or partial activation is incomplete; report it instead of attempting automatic cross-system rollback. -The machine-readable writer, provenance, deploy, completion, and detector examples in [Image promotion contracts](image-promotion-contracts.md) are part of this contract and must be copied with the policy below. +The machine-readable writer, provenance, deploy, and completion examples in [Image promotion contracts](image-promotion-contracts.md) are part of this contract and must be copied with the policy below. ## One desired-state owner -The home repo owns one `images.json` (`infra/images.json`, or root `images.json` in a dedicated infra repo). Announcement validation, the trusted writer, deployment, readback, and drift detection all read its per-app objects: +The home repo owns one `images.json` (`infra/images.json`, or root `images.json` in a dedicated infra repo). Announcement validation, the trusted writer, deployment, and readback all read its per-app objects: ```json @@ -22,8 +22,6 @@ The home repo owns one `images.json` (`infra/images.json`, or root `images.json` }, "imageRepository": "ghcr.io/example/app/web", "registryAccess": "private", - "trackedTag": "main", - "promotionLatencyMinutes": 30, "promotionEnabled": true, "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "promotedSourceSha": "1111111111111111111111111111111111111111" @@ -33,9 +31,13 @@ The home repo owns one `images.json` (`infra/images.json`, or root `images.json` `sourceWorkflow.path` and `sourceWorkflow.id` bind the immutable authorized Actions workflow; a different successful workflow with a job named `build` is not evidence. `registryAccess` is required metadata with exactly two values: `public` requires anonymous manifest access, while `private` requires exact provider visibility `private`, anonymous denial, and authenticated workflow and host access. Every reader first requires a plain object document root, then validates each complete object at runtime: the exact metadata and pin key sets, a GHCR repository, the access enum, and valid paired pins. Arrays, primitives, prototype-bearing objects, unknown fields, and authentication material fail closed. Derive production references only as `imageRepository@digest`. `images.json` is the single declarative state owner being converged, not a third credential ledger of the kind rejected by `CREDS-CLOUDFLARE-001`; it never contains a credential, secret path, username, or authentication-file path. +## Coordinated releases + +When multiple images must be released together, use the [coordinated release extension](image-promotion-groups.md). Its group proof replaces the single-record proof below for those apps. Independent apps keep the single-image contract. + ## Source side: bind and announce the build -The trusted build job publishes `imageRepository:trackedTag`, obtains the registry digest, and emits exactly one single-line JSON record to its immutable job log. The marker is assembled from fragments so the full marker cannot appear in the runner's echoed shell source. A separate announcement job runs only after build success. Its fallback token is read-only; its one-infra-repository App token has only Contents write. +The trusted build job publishes an image under its source-owned tag, obtains the registry digest, and emits exactly one single-line JSON record to its immutable job log. The marker is assembled from fragments so the full marker cannot appear in the runner's echoed shell source. A separate announcement job runs only after build success. Its fallback token is read-only; its one-infra-repository App token has only Contents write. The App credentials live at `ci.broker_app.app_id` and `ci.broker_app.private_key` in `secrets/ci.yaml`. Resolve both with the canonical action, which transports nested multiline values through `GITHUB_ENV`, never outputs. @@ -120,13 +122,19 @@ The `registryAccess` hard cutover has one document-wide migration operation for Private host adoption has a separate two-stage boundary that runs before private metadata or promotion can require a pull. First deploy the SOPS secret, login unit, explicit auth files, and container-unit environment while a new app remains disabled or the existing app remains public. Read back the decrypted secret presence, successful login unit, and root-only private auth file. Only a later reviewed metadata change and trusted promotion may select `private` and require private pre-pull. The same sequence applies to a new private app and a public-to-private migration; an old host never has to pull a private image to install the credential plumbing needed for that pull. -## Deploy, completion, and drift +## Deploy and completion The deploy workflow serializes production without cancellation. Its deploy job depends on a successful gate for exact `github.sha`. Immediately before its first mutation it requires checkout, gated, event, and current remote-main SHAs to be identical, then reruns the shared exact repository/digest registry-access proof from the gated `images.json`. A queued run therefore performs zero mutations when main moved, visibility changed, anonymous access changed, or the job token lost its package grant. It derives full references from the gated `images.json`; every activation must pass exact registry-digest and health readback, followed by all OpenTofu postconditions. Completion filters merged PRs before uniqueness, then authenticates the App bot, canonical same-repository branch, `images.json`-only file set, successful trusted provenance check, and exact resulting pin at the merge SHA. Open and closed marker copies are ignored; forged or multiple merged candidates fail closed. The exact merge-SHA deploy and its one successful deploy job are required. -The scheduled detector has Contents read and Packages read through its per-job `GITHUB_TOKEN`. It records initial desired and observed tag digests. A public entry resolves anonymously and fails when anonymous access stops working. A private entry queries the GitHub package API for the exact package path and fails unless visibility is exactly `private`; inability to read visibility also fails. It then proves anonymous denial and resolves the same digest with the workflow token; grant the infrastructure repository read access in that package's Actions access settings. `internal` is not private: broader organization or enterprise access fails the declared mode even though anonymous access is denied. Missing package access, a package that became public, an invalid access mode, or any registry error fails closed. Only an unchanged mismatch after a complete latency window fails; movement of either value starts a new window. The detector never writes and never decrypts a durable registry credential. +Read provenance through the Checks API for the exact PR head SHA, filter by `trusted-promotion-provenance` and `latest`, paginate, and require exactly one completed, successful GitHub Actions check. A missing, duplicate, failed, pending, wrong-head, or foreign-App check fails closed. The completion reader needs Actions read, Contents read, Pull requests read and Checks read; a reporter that publishes the completion check needs Checks write. Do not request `statusCheckRollup`: it also fetches commit statuses and can fail without `statuses: read`, even when the needed check is readable. Completion does not need access to those unrelated statuses. + +Announcing or opening a promotion proposes a release; it does not authorize deployment. An open or deliberately deferred PR is informational and must not fail a freshness check because a newer image exists. Record the pending proposal and its evidence without treating it as completed deployment. + +Promotion is push-based. Verify the exact approved images and service health during deployment and report completion on the promotion PR. Do not add a scheduled image drift detector, periodic running-image comparisons, publication-age failures, or a promotion latency field. There is no periodic image recheck after successful completion. + +Registry-access checks remain mandatory and independent of release deferral. Use Contents read and Packages read through the per-job `GITHUB_TOKEN` where needed. Public entries must remain anonymously readable. Private entries require exact package visibility `private`, anonymous denial, and digest resolution with the workflow token; grant the infrastructure repository read access in the package's Actions access settings. Missing access, changed visibility, invalid access modes, or registry errors fail closed. Never decrypt a durable registry credential for this check. ## Private GHCR host access @@ -140,4 +148,4 @@ Rotation is replace, refresh, verify, revoke: create a replacement classic PAT w ## Adoption boundary -This contract supports public and private GHCR images. Choose `registryAccess` explicitly during disabled adoption and prove that access mode before the first trusted promotion. Source-repository API access remains a separate plane: a private source repository uses the existing broker App's short-lived Actions-read token for provenance and drift timing, never the registry PAT. Registries other than GHCR and private-image credentials other than the host `read:packages` PAT are out of scope and require a new reviewed contract. +This contract supports public and private GHCR images. Choose `registryAccess` explicitly during disabled adoption and prove that access mode before the first trusted promotion. Source-repository API access remains a separate plane: a private source repository uses the existing broker App's short-lived Actions-read token for build provenance, never the registry PAT. Registries other than GHCR and private-image credentials other than the host `read:packages` PAT are out of scope and require a new reviewed contract. diff --git a/.agents/skills/declarative-infra/references/pr-previews.md b/.agents/skills/declarative-infra/references/pr-previews.md index 973b303..395fdae 100644 --- a/.agents/skills/declarative-infra/references/pr-previews.md +++ b/.agents/skills/declarative-infra/references/pr-previews.md @@ -21,6 +21,8 @@ Two workflows, split so untrusted PR code never runs with secrets: Deploy secrets live in a dedicated GitHub Environment (`pr-preview`) whose SOPS file contains exactly one secret: the preview deploy SSH private key. It never holds production deploy keys or cloud credentials, and its age key is distinct from the production deploy environment's. The preview deploy job and the production deploy job share one Actions concurrency group (`--deploy`, `cancel-in-progress: false`) so their `switch` operations never interleave — this serialization is a load-bearing invariant, since the host-side lock only covers preview invocations. +Give every temporary resource in the trusted deploy job its own path. Create secret files with `mktemp` and artifact extraction directories with `mktemp -d`, using distinct templates for each resource. A fixed basename shared by an SSH key and an artifact directory turns the key file into a guaranteed deployment failure when the artifact step calls `mkdir`. Keep a contract test that reads the SSH helper and deploy workflow together and rejects reused temp paths. + ## Host-side forced command The only mutation channel is an SSH key restricted to a single command in root's `authorized_keys`: @@ -43,10 +45,15 @@ Each active preview materializes, from the modules, as: - a dedicated system user and group at a reserved UID/GID range (e.g. base 200000 + index), the container running `--user` as that identity with `--cap-drop=ALL`, `--security-opt=no-new-privileges`, CPU/memory/pids caps, and a tmpfs `/tmp` - its own Postgres database `_pr_` with peer auth mapped only to that system user (the bootstrap Postgres module's `databaseSystemUsers` seam exists for exactly this) -- its own internal Podman network, so previews cannot reach each other -- a loopback port assigned deterministically (`basePort` + index over the sorted preview set) and a Caddy virtual host `.pr.` that reverse-proxies it and sends `X-Robots-Tag: noindex, nofollow, noarchive` +- its own internal Podman network with a non-overlapping host-reachable subnet and deterministic container address, so previews cannot reach each other or the internet +- a Caddy virtual host `.pr.` that reverse-proxies the container address directly and sends `X-Robots-Tag: noindex, nofollow, noarchive`; do not publish a host port because rootful Podman's internal bridge disables the forwarding that published ports require +- a host firewall rule that rejects connections initiated from the reserved preview subnet range, including their later packets, before any host-service allow rule; this closes the bridge-gateway path to Caddy and other host listeners while allowing replies to host-originated Caddy and readiness traffic + +Assert the invariants in the module: unique bounded PR numbers, digest-pinned images matching the allowed name, and enough addresses in the host-reserved subnet range. + +The image is part of the isolation contract. The dedicated preview UID overrides the image's declared user, so every non-secret runtime file and parent directory must be readable and traversable by an arbitrary unprivileged identity. Run the preview image's smoke test with `--user=:` and the same read-only root, tmpfs mounts, capabilities, and security options used by the host. A smoke test that runs as the image's default user does not validate the deployed shape. -Assert the invariants in the module: unique bounded PR numbers, digest-pinned images matching the allowed name, and enough port room above `basePort`. +If Caddy's admin API is disabled with `admin off`, also set NixOS `services.caddy.enableReload = false`. The generated reload command calls that API and makes an otherwise healthy host activation fail. Configuration changes then restart Caddy, so record the brief interruption in the host profile. ## Lifecycle @@ -65,5 +72,9 @@ One wildcard record `*.pr.` pointing at the host, managed in the tofu st ## Traps - The host-side rebuild evaluates the flake from the deployed system. A path converted with `toString` can disappear after `nix gc` even when `system.extraDependencies` retains a different source path; keep the command's own closure complete instead. +- An image that starts as its declared user can still fail immediately under the host's dedicated preview UID. Exercise the exact UID override in the build workflow. +- A port published from a rootful Podman `--internal` bridge is not a host ingress path. Give each preview a deterministic address on its internal bridge and route host Caddy and readiness probes to that address directly; do not restore forwarding or outbound access to make port publication work. +- Separate internal bridges do not stop a preview from reaching services bound to its host-side gateway. Reject connections initiated from the whole reserved preview source range before host-service allow rules, or a preview can send another preview's hostname to host Caddy and use it as a cross-network proxy. Keep reply-direction packets allowed so host-originated readiness and proxy traffic can return. +- Caddy cannot reload through an admin API configured as `off`; disable reload-on-change or keep the API available. - `workflow_run.pull_requests` can be empty depending on event provenance; guard teardown jobs on the PR number being present instead of assuming `[0]` exists. - The deploy workflow must check out the *default branch*, never the triggering head — `workflow_run` runs with secrets, and the artifact is the only thing taken from the untrusted build. diff --git a/.agents/skills/declarative-infra/references/vendor-image-promotion.md b/.agents/skills/declarative-infra/references/vendor-image-promotion.md new file mode 100644 index 0000000..da30514 --- /dev/null +++ b/.agents/skills/declarative-infra/references/vendor-image-promotion.md @@ -0,0 +1,33 @@ +# Vendor image promotion + +Use this contract for third-party containers whose publisher is outside the infrastructure owner's control. Such images require reviewed, immutable desired-state updates and verified deployment completion. They do not require our source announcement workflow or access to the vendor's private Actions logs. + +## Publisher and desired state + +The infrastructure repository owns a strict manifest with each release family's approved image repositories, allowed tag or version policy, release-notes URL, selected version, and per-image digest. Reject unknown fields, malformed references, duplicate ownership, and credentials. Derive each deployed reference exclusively from this manifest as `repository@sha256:digest`. + +Prefer the official upstream publisher. A personal fork needs a documented server customization or another concrete reason, plus responsibility for keeping it current. Repository ownership alone does not turn third-party software into an internally maintained application. Changing publishers is a reviewed metadata change, with runtime, migration, and data-format compatibility checked before deployment. + +Existing immutable production pins may be adopted unchanged into the manifest without republishing a vendor build or disabling the application. Show that adoption preserves the evaluated container references. Do not duplicate a pin in both source and vendor manifests. + +## Discovery and review + +A scheduled or manually invoked trusted workflow discovers versions only within the approved repository and tag policy. Prefer stable release tags when provided. If upstream only publishes a moving tag, record that choice and resolve it to a digest for each proposal. Tag age does not impose a deployment deadline. + +This contract covers public GHCR and Docker Hub images. Normalize Docker Hub names for registry requests without changing the declared publisher. Use anonymous pull-scoped registry tokens, restrict pagination to the same registry and repository, bound requests, and validate returned digests. Resolve all release-family members before writing anything. Registry errors, missing members, invalid versions, or ambiguous selection fail discovery; they must not produce a partial update or a successful no-op. + +The updater opens a manifest-only PR containing the previous and proposed immutable references, selected version, publisher, and upstream release notes. Its canonical candidate identity includes the complete repository-to-digest map. Reruns reuse the same open candidate; a closed candidate remains closed. A newer candidate can be proposed while an older one is deliberately deferred. Do not execute vendor source or image contents in a workflow holding a write token. + +Human review authorizes the publisher and release. Registry availability proves pullability, not authenticity or application compatibility. Verify upstream signatures or attestations when the declared publisher policy requires them. Review migration prerequisites and backups for data-bearing upgrades. Do not enable automatic merge as part of adoption; any later automation requires a separate explicit release policy. + +## Release families and deployment + +Images published as one vendor release, such as Immich server and machine learning, form a release family. Select the same upstream version for every member and pin them in one PR. A separately versioned database or cache remains a separate family unless upstream declares a joint version contract. Vendor images need not share a source SHA or Actions run. + +One PR coordinates desired state, but container rollout remains sequential. Establish mixed-version compatibility or stop the family before shared migrations. Pre-pull every selected digest first and fail the whole deployment when a required member or migration fails. + +Use the infrastructure home's exact-commit quality gate, current-main guard, serialized deployment, and pre-mutation registry verification. Completion belongs to the exact merged PR, infrastructure merge SHA, and successful deploy attempt. Require every affected image's runtime digest and health plus all required infrastructure postconditions. A merge, successful pull, or another commit's deployment does not prove completion. Attach a deployment result and evidence link to the original PR. + +A rollback is a new reviewed manifest change with verified old digests and an explicit database recovery decision where needed. Never move a tag or reactivate a closed candidate to bypass review. + +An available version is informational until approved through merge. Report failed discovery separately from failed deployment. Verify approved images and health during deployment. Do not add a scheduled image drift detector or deployment deadline. diff --git a/.agents/skills/review-fix/SKILL.md b/.agents/skills/review-fix/SKILL.md index 3440804..308d19c 100644 --- a/.agents/skills/review-fix/SKILL.md +++ b/.agents/skills/review-fix/SKILL.md @@ -13,7 +13,7 @@ An explicit user choice wins. Otherwise use Claude Opus 5 at high effort in Clau ## Scope -Read `.agents/review/decisions.md` when present. Post one scope comment with the intent, threat model, out-of-scope work, and selected lenses; its timestamp starts the cycle. Ask about splitting only when the PR contains independent product outcomes. +Post one scope comment with the intent, threat model, out-of-scope work, and selected lenses; its timestamp starts the cycle. Ask about splitting only when the PR contains independent product outcomes. Choose distinct lenses: @@ -35,18 +35,20 @@ Add a specialized lens only when a material risk such as authorization, persiste Reuse a successful equivalent exact-head gate. Otherwise run the repository gate once. Repair only PR-introduced mechanical failures before review and record pre-existing failures. -Run `review-pass` over the PR base → initial head with the scope, gate result, decisions registry, lenses, and any model override. Retry a skipped lens once, then stop if coverage is still incomplete. Merge duplicate findings while preserving every reporting lens, and assign one decision: +For every required review or verification lens, spawn a separate read-only subagent using the [review skill](../review/SKILL.md). `review-pass` is an optional workflow helper. If delegation is unavailable, report incomplete coverage and stop. + +Review the PR base → initial head with the scope, gate result, lenses, and any model override. Retry a skipped lens once, then stop if coverage is still incomplete. Merge duplicate findings while preserving every reporting lens, and assign one decision: - `block`: demonstrated, in scope, material under the threat model, and worth stopping the merge; - `defer`: real but outside this PR or below the merge bar; - `discard`: refuted, speculative, already accepted, or not worth scheduling; - `ask`: a costly, durable product or architecture choice remains unresolved. -Do not ask about inferable implementation details, naming, local refactors, test shape, or other reversible choices. Choose the smallest sound option and record durable assumptions. Collect every unavoidable `ask` into one decision brief with the options, consequences, and a recommendation. +Do not ask about inferable implementation details, naming, local refactors, test shape, or other reversible choices. Choose the smallest sound option. Collect every unavoidable `ask` into one decision brief with the options, consequences, and a recommendation. ## Fix -Create one self-contained review thread per blocker. A worker reproduces the failure first, makes the smallest correction, and runs focused checks. If it cannot reproduce the failure, leave the thread unresolved and reclassify the finding instead of fixing it speculatively. +Create one self-contained review thread per blocker. A worker subagent reproduces the failure first, makes the smallest correction, and runs focused checks. If it cannot reproduce the failure, leave the thread unresolved and reclassify the finding instead of fixing it speculatively. Add at most one regression test per failure class, preferably by extending existing or table-driven coverage. The test should fail on the pre-fix behavior when practical. Do not add a dependency, parser, fixture framework, generic harness, or shared guard for one finding. Use browser tests only for browser-specific behavior. diff --git a/.agents/skills/review/SKILL.md b/.agents/skills/review/SKILL.md index 2d7db3f..9ff4709 100644 --- a/.agents/skills/review/SKILL.md +++ b/.agents/skills/review/SKILL.md @@ -13,7 +13,6 @@ Review the requested change without editing it. The goal is a trustworthy merge - Ground findings in inspected code, repository contracts, tests, command output, or documented framework behavior. - Show a reachable failure scenario. Suspicious patterns or theoretical possibilities alone are not findings. - Judge materiality against the supplied intent and threat model. Repository-rule drift is evidence, not automatically a blocker. -- Read `.agents/review/decisions.md` when present and do not reopen a still-valid decision without new evidence. - If an exact-head gate result was supplied, do not rerun the full gate. Use focused probes only. Instrumented probes belong in a disposable worktree; never modify the shared checkout. Enumerate the surfaces owned by the lens rather than sampling them. Read other files when they prove an in-lens finding, but do not duplicate another lens’s charter. @@ -24,7 +23,7 @@ Return exactly one decision per finding; do not add a separate severity: - **block** — demonstrated, in intent, material under the threat model, and serious enough to stop this merge. - **defer** — real and actionable, but outside the PR or below the merge bar. -- **discard** — refuted, speculative, already accepted, or too low-value to schedule. Report only durable discards worth recording. +- **discard** — refuted, speculative, already accepted, or too low-value to schedule. - **ask** — the repository cannot choose between materially different durable product or architecture outcomes, and choosing wrongly would be expensive to reverse. Do not ask about inferable implementation details, reversible choices, local refactors, naming, or test shape. Prefer the smallest in-scope correction and defer optional machinery. diff --git a/.agents/skills/standards-sync/references/github.md b/.agents/skills/standards-sync/references/github.md index 770df50..829433a 100644 --- a/.agents/skills/standards-sync/references/github.md +++ b/.agents/skills/standards-sync/references/github.md @@ -20,3 +20,5 @@ bun standards creds add github --dest ci:ci.broker_app ``` The workflow mints two short-lived tokens for the current repository: a branch writer for contents and workflows, and a pull-request opener. Neither token enters the sync process, and there is no fallback credential. A repository with `autoSync: false` does not need these permissions until automatic sync is re-enabled. + +Canonical sync branches contain trusted upstream code and may run consumer CI before the generated PR is reviewed. diff --git a/.agents/skills/unslop/LICENSE.txt b/.agents/skills/unslop/LICENSE.txt deleted file mode 100644 index 6b54002..0000000 --- a/.agents/skills/unslop/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Lauren Tan - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/.agents/skills/unslop/SKILL.md b/.agents/skills/unslop/SKILL.md deleted file mode 100644 index 2a93c06..0000000 --- a/.agents/skills/unslop/SKILL.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -name: unslop -description: Cut AI tells from any writing. Must always apply. ---- - -# Unslop - -Edit text to remove AI patterns and add human voice. - -## Process - -1. Scan for the patterns below. -2. Rewrite. Preserve meaning, match intended tone. -3. Add soul (see next section). -4. Self-audit: "What makes this obviously AI generated?" Fix remaining tells. - -## Adding soul - -Removing patterns is half the job. Sterile, voiceless writing is just as obvious. - -- **Have opinions.** React to facts instead of neutrally listing pros and cons. -- **Vary rhythm.** Short sentences. Then longer ones that take their time. Mix it up. -- **Acknowledge complexity.** "Impressive but also kind of unsettling" beats "impressive." -- **Use "I" when it fits.** First person isn't unprofessional. -- **Let some mess in.** Perfect structure looks machine-made. -- **Be specific.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am." - -## Patterns to detect and fix - -### Content - -1. **Puffery.** "pivotal moment", "testament to", "evolving landscape", "setting the stage for", "indelible mark", "deeply rooted". Cut puffery, state what happened. -2. **Name-dropping.** Listing media outlets without context. Pick one, say what was said. -3. **Superficial -ing phrases.** "highlighting...", "ensuring...", "reflecting...", "showcasing...", "fostering...". Delete or expand with real sources. -4. **Promotional language.** "nestled", "vibrant", "breathtaking", "groundbreaking", "renowned", "stunning", "must-visit". Use neutral descriptions. -5. **Vague attributions.** "Experts believe", "Industry reports suggest", "Some critics argue". Name the source or delete. -6. **Formulaic challenges.** "Despite challenges... continues to thrive." Replace with specific facts. - -### Language - -7. **AI vocabulary.** Additionally, crucial, delve, enduring, enhance, fostering, garner, interplay, intricate, landscape (abstract), pivotal, showcase, tapestry (abstract), testament, underscore, vibrant. Replace with plain words. -8. **Fancy ways to say "is".** "serves as", "stands as", "boasts", "features". Just say "is" or "has". -9. **"Not just X, but Y."** State the point directly instead. -10. **Rule of three.** Forcing ideas into groups of three. Use the natural number. -11. **Synonym cycling.** Protagonist, main character, central figure, hero all in one paragraph. Pick one, repeat it. -12. **False ranges.** "from X to Y" where X and Y aren't on a meaningful scale. List topics directly. - -### Style - -13. **Em dash overuse.** Avoid em dashes entirely. Use periods or commas only (no parentheses, no en dashes, no hyphen-as-dash substitutes). Em dashes are an AI tell, and reaching for parentheses instead just trades one tell for another. If a thought needs separation, end the sentence or use a comma. -14. **Colon overuse.** Colons are fine before a list or example. Not as mid-sentence connectors. "If you're coming from traditional automation: instead of registering event handlers, you describe conditions" adds nothing with the colon. Rewrite to let the point stand on its own without comparison framing. "Describing when the scheduler should fire works best as plain English." Same meaning, no crutch punctuation. -15. **Boldface overuse.** Don't bold every proper noun or acronym. -16. **Inline-header lists.** The tell is a bold label and colon that restates the line: "**Performance:** Performance improved...". Convert those to prose. A bold lead-in that ends in a period, names the item, and is followed by genuinely new detail ("**Schema in TypeScript.** Tables live in one file.") is fine, not a tell. -17. **Title case headings.** Use sentence case. -18. **Decorative emojis.** Remove from headings and bullets. -19. **Curly quotes.** Replace with straight quotes. - -### Communication artifacts - -20. **Chatbot phrases.** "I hope this helps!", "Let me know if...", "Of course!", "Certainly!", "Found the smoking gun!" Remove. -21. **Cutoff disclaimers.** "While specific details are limited..." Find sources or remove. -22. **Sycophantic tone.** "Great question! You're absolutely right!" Respond directly. - -### Filler - -23. **Filler phrases.** "In order to" becomes "To". "Due to the fact that" becomes "Because". "It is important to note that" gets deleted. -24. **Excessive hedging.** "could potentially possibly be argued that it might" becomes "may". -25. **Generic conclusions.** "The future looks bright." State specific plans or facts. - -### Jargon - -26. **Abstract metaphor nouns.** Substrate, wedge, vector, locus, vantage, nexus, primitive (as noun), harness (as metaphor), surface (as in "API surface"), bedrock, scaffolding (as metaphor), modality, paradigm, gold-plating, ratchet (as metaphor), evacuate (for moving code), endgame, north star, flywheel. These read as technical but usually have a plainer concrete word. "Substrate" becomes "base". "Wedge in" becomes "add". "Vector" becomes "way" or "method". "Gold-plating" becomes "more than the job needs". "Ratchet" becomes the mechanism's real name or "a limit that only tightens". "Evacuate" becomes "move out". "Endgame" becomes "the last phase". Pick the concrete word. - -### Plain speech - -27. **Say what it does, not how it feels.** "the database stays close at hand", "SQL you can read", "types that follow your schema" name a feeling. The fix names the mechanism or a number: "`.toSQL()` returns the exact string sent to the database", "a column rename fails the build". Ask what the sentence tells the reader to do or know, then write that. If you can't restate it as a concrete instruction, fact, or number, cut it. One more check: if the sentence could appear unchanged in another project's docs, it says nothing about this one. Cut it. -28. **Shorten or split dense sentences.** If the reader has to backtrack to parse a sentence, break it in two or drop clauses. One idea per sentence. -29. **Active voice.** Prefer it. Catch "is/are/was/were + past participle" and name the actor: "queries are validated" becomes "the compiler validates queries", "the file is parsed by the loader" becomes "the loader parses the file". Passive is fine only when the actor is unknown or genuinely doesn't matter. -30. **Cut adverbs, or use a stronger verb.** "runs quickly" becomes "is fast" or the number. "significantly improves" becomes the measured delta. An adverb propping up a weak verb means the verb is wrong. -31. **Prefer the plain word.** "utilize" becomes "use", "leverage" becomes "use", "facilitate" becomes "help", "numerous" becomes "many", "in the event that" becomes "if". The fancier synonym is rarely clearer. diff --git a/.agents/skills/unslop/agents/openai.yaml b/.agents/skills/unslop/agents/openai.yaml deleted file mode 100644 index 8dc1480..0000000 --- a/.agents/skills/unslop/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Unslop" - short_description: "Remove AI tells and add a natural human voice" - default_prompt: "Use $unslop to rewrite this text without AI tells while preserving its meaning and tone." diff --git a/.github/workflows/standards-sync.yml b/.github/workflows/standards-sync.yml index 615343c..ea24495 100644 --- a/.github/workflows/standards-sync.yml +++ b/.github/workflows/standards-sync.yml @@ -97,7 +97,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.4.0 + bun-version: 1.4.2 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/standards.yml b/.github/workflows/standards.yml index cc18caa..3e7bd53 100644 --- a/.github/workflows/standards.yml +++ b/.github/workflows/standards.yml @@ -269,6 +269,9 @@ jobs: echo "::error::Found a browser a11y suite but no installed playwright binary. Declare @playwright/test in the workspace that owns the suite." exit 1 fi + # Playwright downloads its own Chromium. The runner's unused Chrome APT + # repository can break dependency installation with inconsistent metadata. + sudo rm -f /etc/apt/sources.list.d/google-chrome.list /etc/apt/sources.list.d/google-chrome.sources "$playwright" install --with-deps chromium - name: Check @@ -403,10 +406,10 @@ jobs: - name: Install pinned settings checker run: | set -euo pipefail - bun_version=1.4.0 + bun_version=1.4.2 case "$(uname -m)" in - x86_64) bun_arch=x64; bun_sha=2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452 ;; - aarch64) bun_arch=aarch64; bun_sha=4b1a332ee861983eb93bcfe6f770fff94e3e31b2c388bdaea3c8ed35e58eed0e ;; + x86_64) bun_arch=x64; bun_sha=36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913 ;; + aarch64) bun_arch=aarch64; bun_sha=54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7 ;; *) echo "::error::No pinned Bun binary for runner architecture $(uname -m)" exit 1 diff --git a/AGENTS.md b/AGENTS.md index 4c049b5..02bec9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,23 +1,18 @@ # AGENTS.md -This file is the root operating contract for agents in this repository. Keep root instructions for non-negotiable constraints; put specialized workflows in `.agents/skills/*/SKILL.md`. - ## Quality gates -- Never weaken a quality gate (lint, types, tests, a11y) to make a change pass. Fix findings in the code instead of downgrading or disabling rules. -- Every inline suppression needs a reason. Use a per-file override only when a rule genuinely cannot apply, narrowed to that path and rule. +Do not weaken quality gates to make a change pass. Explain inline suppressions. Use configuration exceptions only where a rule cannot apply, scoped to the affected path and rule. ## Change policy - Do not build backwards compatibility by default. Migrate every call site and delete the old shape in the same change. Do not add deprecated aliases, versioned copies, or compatibility-only optional parameters. - Ask before choosing product intent or another costly, durable direction. Assume no background knowledge or familiarity with the code; explain what is at stake, where each option leads, and recommend one before presenting technical evidence. -- Propose before changing CI workflows, quality gates, or canonical synced files, even to unblock a failure. The file class is the trigger. ## Package management - Use Bun only, at the exact version declared by the root `packageManager`. -- Add dependencies with `bun add`; do not manually edit dependency versions into `package.json`. -- Workspaces that rely on Bun runtime or `bun:test` types must declare `@types/bun`, not custom ambient declaration shims. +- Workspaces using Bun runtime or `bun:test` types must declare `@types/bun`. ## Architecture @@ -35,28 +30,22 @@ This file is the root operating contract for agents in this repository. Keep roo ## Effect standards -- Decode untrusted input with Schema before using it. -- Required for async work, concurrency, retries, timeouts, resource acquisition, cancellation, and injected dependencies; at service boundaries the error and requirement channels are the contract. -- Not required for total synchronous logic or UI components, which stay plain and consume Effect at the boundary. -- Never `throw` for expected failures; return typed Effect errors. Recoverable errors are `Data.TaggedError` classes with stable `_tag` values and actionable `message` fields. -- A workspace may opt out wholesale only for a stated architectural reason recorded in `AGENTS.local.md`; do not mix idioms inside one workspace. +- Use Effect extensively where it makes code more robust. Keep simple synchronous logic and UI components plain, integrating Effect at boundaries. +- Service contracts expose typed errors and requirements. Represent expected failures with `Data.TaggedError`, a stable `_tag`, and an actionable `message` instead of throwing. +- Workspace-wide exceptions require an architectural reason in `AGENTS.local.md`; keep each workspace consistent. ## Writing style +- Write plainly and directly. Avoid mannered prose, decorative metaphors, and stock phrases. Prefer literal wording and sentences that are easy to follow. - Use sentence case for reader-facing text — UI copy, labels, command-style actions, Markdown headings — preserving proper nouns, acronyms, filenames, package names, and domain terms. -- Comment only non-obvious intent. - Do not hard-wrap Markdown prose; keep each paragraph or list item on one logical line. -## Definition of done - -1. Test changed behavior and regression-prone states. Do not add tests that only pin trivial copy, static literals, or type-impossible states. -2. Search for stale references to changed concepts, names, paths, configuration, secrets, commands, public APIs, error types, or architectural patterns. Update docs and SOPS secret examples when needed. -3. Run `bun run check:fix` from the repo root for code changes. If it fails, read the full error, fix the root cause, and run it again. +## Documentation -For documentation-only changes, run a narrower verification when the full check would not add useful signal. +Write documentation when it helps someone use, operate, or change the project. Keep it concise and current; do not narrate the implementation or repeat what the code makes clear. Put local rationale near the code and change history in PRs. ## Project-specific rules -This file is canonical and synced from the standards template — do not edit it locally. Project-specific rules that extend this contract live in `AGENTS.local.md`; add local guidance there instead. +This is a canonical file from the standards repository. Project-specific rules belong in `AGENTS.local.md`. @AGENTS.local.md diff --git a/nix/standards-bun.nix b/nix/standards-bun.nix index 480f667..141de24 100644 --- a/nix/standards-bun.nix +++ b/nix/standards-bun.nix @@ -7,7 +7,9 @@ }: let bunVersion = - if builtins.match "^bun@(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" packageManager == null then + if + builtins.match "^bun@(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" packageManager == null + then throw "packageManager must pin an exact bun@x.y.z version" else lib.removePrefix "bun@" packageManager; @@ -22,6 +24,16 @@ let hash = "sha256-SxozLuhhmD65O8/m93D/+U4+MbLDiL2uo8jtNeWO7Q4="; }; }; + "1.4.2" = { + x86_64-linux = { + asset = "bun-linux-x64-baseline.zip"; + hash = "sha256-xngEDxT+BEDrg503y9DOTAUaMtpygGrJfeamqra/co8="; + }; + aarch64-linux = { + asset = "bun-linux-aarch64.zip"; + hash = "sha256-VDKLvC2cjgyfiSxUTWbFeoO4QTnjSQnl7oF1jxrI/ac="; + }; + }; }; versionSources = sources.${bunVersion} @@ -30,10 +42,12 @@ let versionSources.${stdenv.hostPlatform.system} or (throw "Bun ${bunVersion} is not available for ${stdenv.hostPlatform.system}"); in -bun.overrideAttrs (_final: _previous: { - version = bunVersion; - src = fetchurl { - url = "https://github.com/oven-sh/bun/releases/download/bun-v${bunVersion}/${source.asset}"; - inherit (source) hash; - }; -}) +bun.overrideAttrs ( + _final: _previous: { + version = bunVersion; + src = fetchurl { + url = "https://github.com/oven-sh/bun/releases/download/bun-v${bunVersion}/${source.asset}"; + inherit (source) hash; + }; + } +) diff --git a/sync-standards.json b/sync-standards.json index 1659a29..86a6df3 100644 --- a/sync-standards.json +++ b/sync-standards.json @@ -17,7 +17,6 @@ ".agents/skills/secrets-and-config", ".agents/skills/screenshots-in-prs", ".agents/skills/standards-sync", - ".agents/skills/unslop", ".agents/skills/ux-ui", ".claude/skills", ".claude/agents/reviewer.md", diff --git a/sync-standards.lock b/sync-standards.lock index 66bcc37..7e9a8f1 100644 --- a/sync-standards.lock +++ b/sync-standards.lock @@ -1,33 +1,32 @@ { "upstream": "github:davidvornholt/standards", - "sha": "685e1b463a29ab3671e264e582053e115ef383d3", + "sha": "343a9d3b795c204bda384e16d6c731051f9851c2", "files": { - ".agents/skills/ci-pipelines/SKILL.md": "87728fdc98f9ac95df243ef4c1d44d616cc0440e2ae384da28dda83d627e00a0", + ".agents/skills/ci-pipelines/SKILL.md": "2b9acdddb838273af146f1aaaecaa0edbb9ca460da8bb32535fe06a74e7e2cba", ".agents/skills/database/agents/openai.yaml": "4c9f47df24219d5b195c88eef374431f64b453ed1ba057f50d4c11e4549343c1", ".agents/skills/database/SKILL.md": "cc89d5118e76537321ec3f7da1ef46fcbc7606ab6b44a630ef80373e6f28f988", ".agents/skills/declarative-infra/agents/openai.yaml": "d8631747495397fa341d956c20d7b3155b68f8f5a86b9d8015e654cb61e3d649", - ".agents/skills/declarative-infra/references/bootstrap.md": "c58fbf33207d471364ab1ffe4aae870d0a6a9285f0a1160137cccf3ee5dd6f47", - ".agents/skills/declarative-infra/references/image-promotion-contracts.md": "127b73ede1e3210e59cf8ac90c3a14b2401a486e32d0ee70f02ad08cc305b121", - ".agents/skills/declarative-infra/references/image-promotion.md": "7b363f5f4b360cab3ea5f8366581e8b4bafdd33ef16284b9ef8ddea38517429f", - ".agents/skills/declarative-infra/references/pr-previews.md": "3cb88f6a0216027ad7dff60275e186cab9166a734cb513c7d2def74287fd8f4b", + ".agents/skills/declarative-infra/references/bootstrap.md": "a7e6c96de118870f43059ab745b72fcd9c387bb5cc2e25c65393f95442303f01", + ".agents/skills/declarative-infra/references/image-promotion-contracts.md": "2cb57660e0ee45ec9b2bb48add089b960d986e174a0763ee74a5223e9ca0b33c", + ".agents/skills/declarative-infra/references/image-promotion-groups.md": "dc6e8f2ad41a47c11f5a2d438c45f4ff3def7a2dca6584a85c958873e91c97f5", + ".agents/skills/declarative-infra/references/image-promotion.md": "d5796d16e668c8e335b8b824a99adde3131c138cb32236b33bb5f055ef59a16b", + ".agents/skills/declarative-infra/references/pr-previews.md": "6553e36e29976f736570661b757b23c246f45ee01ccafba6ad8ef35c2bd47a89", ".agents/skills/declarative-infra/references/secrets.md": "0c7f0a6c2a4243fc58a4568d426071720a58ae07f29cada1e63ac49b5026ef3f", - ".agents/skills/declarative-infra/SKILL.md": "870e68c440404ae4bf45bbe6da472158e9047c61b73c45369810941a4c0202ee", + ".agents/skills/declarative-infra/references/vendor-image-promotion.md": "8ab7ab748cd428c30ab79c1d46489748c96768bb32ab7d88f3bd1ba8ff9ad683", + ".agents/skills/declarative-infra/SKILL.md": "58452e1ddc6f4f972a16d3b697e50696f2021a1f5ab319ef993774ab418669cb", ".agents/skills/frontend-design/LICENSE.txt": "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594", ".agents/skills/frontend-design/SKILL.md": "92e164e0553d9551688107161189fb8b9d936ebaf3c432f217d131fcb144ca3e", ".agents/skills/issues/SKILL.md": "c0e3304564a7b5d532bc69995ae86f0e45d3fdaa1bdc1b383fcf4dded7ac62d9", ".agents/skills/nextjs/SKILL.md": "f9cd2a37e05246f3a371efed3a71380c3b83f9c0ee32371d57ac0f4f260cd005", ".agents/skills/prs/SKILL.md": "03eadfac76d5b1e92a576e41e18d0eb64c3c98da6003f79dcda6fe0d069acddc", - ".agents/skills/review-fix/SKILL.md": "d6a2a255fe713a9b23b0f3652d4df68a1894a82177ae6398db0ca917ef7409b8", - ".agents/skills/review/SKILL.md": "4ca22b900fda25a9d303b017b0059bcd4a29743f5f50f1c7d5d02f24efc9508c", + ".agents/skills/review-fix/SKILL.md": "2cb0cd4a50b477f13d2a6cf431a8ec7c6c2b5203feb109a8751a9578b53234c6", + ".agents/skills/review/SKILL.md": "5e0f3b899ca68e1b124a0d60fc9b9db0e8262bce9f3c73963494bc556edf835e", ".agents/skills/screenshots-in-prs/agents/openai.yaml": "c412b92301b5d1ab50f62def7b845adf27b7a10bfd9491e270f544a7500661af", ".agents/skills/screenshots-in-prs/SKILL.md": "1b0992cf3d5a7c6a42ddfa66f0f3e7d233e1e94553e12f2e76d1915d7a358f21", ".agents/skills/secrets-and-config/SKILL.md": "5d8d0360ed533f507b441db5d703a091262cee9a3c40b22c6586a00f2e0b3081", ".agents/skills/standards-sync/agents/openai.yaml": "438b20601fde0e393a7d431f9795b6b9ac9bed256386d93ab414de917130c478", - ".agents/skills/standards-sync/references/github.md": "5c764f71d0d691d218f23b33fa5abf4425dd70c6f637837e5d15b05e9c1c04cd", + ".agents/skills/standards-sync/references/github.md": "6ffcffab7014f1b169ae570be1aeed7d588240e65fb85eeed6a658fb649325b3", ".agents/skills/standards-sync/SKILL.md": "b04b516cf2bc333a4636f3d734022b115983b300df2300e962b9f95326711e62", - ".agents/skills/unslop/agents/openai.yaml": "572b933f5f24d650386308d288ec3465a3e26ddcc95ec83e64b13fc46b8ab0ee", - ".agents/skills/unslop/LICENSE.txt": "bc957ca6bee02792566a1a028d105e02e247c6e77cf057061674273da77b200e", - ".agents/skills/unslop/SKILL.md": "181883e539caec8258ec9129e3ba5f133409144a2cbf2aa361158ab94cfc3441", ".agents/skills/ux-ui/SKILL.md": "b6edf5c2ccba99517542a147d38d5b8468fe2faf322ab04683a61fee5030be15", ".claude/agents/reviewer.md": "693e55cbae5f28954cefda6c475e97ddf62f4a58216c3cdf792852104b9e8d76", ".claude/skills": "46e7c0c6e8f3bd5ac2cb8978db058041d60b9034ba2a60c7268777a1438ff117", @@ -37,14 +36,14 @@ ".github/dependabot.base.yml": "1ef94dc3ecc9e3e39f34042bb37cfbf128f8dcde56b14243d6315f1e1b783db3", ".github/settings.json": "a06b6aa69b27bf1c3b8ebf25d90131a9653f54bc1ec08384a92581627527911c", ".github/workflows/notify-pause.yml": "ea228580fb33b34dd5c41a690f09158807d4486cf0647e557f830486a6d7a0f3", - ".github/workflows/standards-sync.yml": "5d77b45d539945967a9d61d6e4bb3b57b3d48ef6dad09017a3455a801657b7f2", - ".github/workflows/standards.yml": "ce9af99341a549cb91b2ae430a6a774d20bdb6c0396fc2b7d9020c9815fcb26a", + ".github/workflows/standards-sync.yml": "0565e5d9c1dc7f8ac08fe433c8866ea36853a502b7f96c2988361f0e9e6f3ae8", + ".github/workflows/standards.yml": "6aa762b627462fe74999c9a2ef8e89f560c7523cd2a899b9ce6bdaa9eb4c52bc", ".mise/config.toml": "650a919aa3f06f5dfe20a047250c84f7afbfcc8886862c4421c61e815d2fd3f6", - "AGENTS.md": "3feb223fc28578673c7b8fd58a682250dba166a89487dfb389ac58c61387851a", + "AGENTS.md": "8109abf34f1ce0a6175bffb08bf6993a937a4bbdb368812a1d2ac89bf1d2ba3d", "biome.base.jsonc": "abf829f063a411a369d6ab8241b0c1b37149ee7d444cb962e5050abdc1d10c4a", "CLAUDE.md": "336cc4fbf19beaada7ccf9986414fa91851a8d7a07dfb3ccbe800a69eed0ab49", "justfile": "2b52e2a98fbdbe43aef6134e08f1937642f7fb3aa46f195e94fb01793465a45c", - "nix/standards-bun.nix": "5b1776c85e29fc3936f0bd5c9b3ef2832ecadf4508ef33c984cf8993e7a7012a", + "nix/standards-bun.nix": "395e7524721e2dc6b3a558ddadfbeef4e371da900401bf590ba687304eb5160a", "packages/a11y-testing/package.json": "930542057f329fff22aab3c101e64e09fb2202221a671db75cd61b2025651470", "packages/a11y-testing/README.md": "73be1fcbf6e63df2e3e28cb3355fe03ee00452d82fa06497a01fd963dc4488be", "packages/a11y-testing/src/axe.ts": "8dc8fee67bc124ebef4b8eefa3aa8cc3deb22522fa134d455a8156d5b20a7949", @@ -60,6 +59,6 @@ "packages/typescript-config/tanstack-start.json": "8a9102e47af6a72daab2b9f0baaa53dea98da19651fb01002dfe380ab9056cc5", "packages/typescript-config/tsconfig.json": "a7ad2417a335f31b5ce66273618ce64bf365a10a58cb06c598487a68ead7391c", "secrets.just": "c1d4d6557df5d96702179717839cee435b63df4f36fe38581a32a6359c6fd548", - "sync-standards.json": "81de04114ab939898798ee19916e075ce36a8821446dbdd55637de31b6289305" + "sync-standards.json": "8f59d1a02d09b4178882a21a0799c2fdc6d7c0bf2820ef5d09908492971cb1a7" } }