Skip to content

#367 feat: notification composer, preview and test-send (epic #359) - #456

Merged
gregoryfoster merged 12 commits into
mainfrom
367-notification-composer
Oct 30, 2026
Merged

gregoryfoster merged 12 commits into
mainfrom
367-notification-composer

Conversation

@gregoryfoster

Copy link
Copy Markdown
Contributor

Closes #367 (epic #359).

Notification composer, live preview and dashboard-only test-send, built to the coordinator brief's D1–D7 (issuecomment-6529430076). The implementer review is issuecomment-6529775505; the report and post-merge host steps are issuecomment-6531358718.

  • Composer (src/core/notifications/): ported from Watcher 3ba20be, with the default text word for word.
    • The context is built field by field and pinned to TEMPLATE_VARIABLES (D1).
    • Every render is sandboxed, with bounded */**, and never returns template source (D2).
  • Diff loader (D4): reads gs://co-gcs-processor only, through the processor-text-reader unit credential with a SetCredential= fallback. Each read is size-capped and hash-checked, and the loader never raises.
  • Notifier.dispatch_composed (D3): the adapter's one dispatch. It sends the fixed templates {{ title }}/{{ body }}, with the composed text as variables. An AST boundary test and a respx test pin it.
  • Dashboard:
  • ARCHIVER_PUBLIC_BASE_URL is an origin (Q1). dashboard_url → /dashboard/info-items/{id}. v4.40.0 changelog [service]; SDK unchanged.
  • deploy/archiver.service: sets the bucket and the base URL, and loads the key with LoadCredential= plus the SetCredential= fallback. No GOOGLE_APPLICATION_CREDENTIALS.

Verification:

  • Full suite: 4987 passed, 2 skipped. ruff is clean.
  • On 8001: previews of all three events, a Chromium live-preview check, and a test-send to dev-sink (dispatch 01M672JR9QV071539XKXKGPC4D, succeeded). Nothing was sent on 8000.
  • CR --fix: 3 fixes, 3 observations.

Host step 1 is done:

  • the .env base-URL line is removed;
  • the SA key is installed root:root 600;
  • a pre-flight read of a missing object returned a clean 404.

🤖 Generated with Claude Code

gregoryfoster and others added 12 commits October 30, 2026 18:24
…e origin

- src/core/notifications/diff.py: Watcher 3ba20be, unchanged but the
  ContentOptions import, with its tests and the byte-exact WSLCB fixtures
  (excluded from the whitespace hooks).
- Operator Q1 (2026-10-30): the base is the origin, and an item's page is
  /dashboard/info-items/{id} under it. The API built {base}/info-items/{id},
  a 404 under any origin. src/core/public_base_url.py now serves the API and
  the composer; a base that is not an origin yields no link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ield by field

- content.py: an event (co-core's model as is), ItemFacts and one row's
  ContentOptions in; a title and a body out. Watcher's layout, toggles and
  diff caps, but the context is built field by field (D1), every render is
  sandboxed and nothing returns template source (D2). compose() is the send
  path: a failing custom template renders the event's default, with a
  WARNING naming the subscription and the error type.
- default_templates.py: Watcher's text word for word (shadow parity), with
  Archiver's names and item link; TEMPLATE_VARIABLES is pinned equal to the
  context.
- preview_fixtures.py: one co-core event per kind, an item, the canned texts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s facts

- diff_loader.py (D4): the URI comes on the event. Processor bucket only,
  size-checked then a ranged read, hash-checked against the fingerprint,
  off the loop, never raises. The client is built from the
  processor-text-reader credential explicitly, never ambient; no key
  degrades every diff to unavailable.
- item_facts.py (D1): name/description from the item, url/domain from the
  announcing source, the resolved cadence; tags inert until #362.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Notifier.dispatch_composed sends title_template='{{ title }}',
body_template='{{ body }}' and the text as variables, so Notifier never
evaluates composed text as Jinja. One attempt; every failure is
NotifierUnavailable without the key. The boundary test now allows dispatch
in that one function and pins its shape; send_test, redeliver and the
channel writes stay banned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- D5: each content block gets its Jinja title and body; POST /preview
  renders the posted form strict in the sandbox against the fixtures, no
  Notifier call. Preview and save share ContentOptions' validator; a
  syntax error names its block. #363's carry-over and override refusal
  go: the form is the whole truth.
- D6: POST /{id}/test-send, edit page only, static confirm naming the
  channel: the row's saved options against the canned change, [Test]
  title, one dispatch with archiver:test:{id}:{ULID} and test metadata;
  the dispatch id or the reason as a flash; one INFO line of ids.
- Docs: NOTIFICATION_COMPOSER.md (new), NOTIFICATIONS.md, PAGES.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… D4 proof CLI

- archiver.service: ARCHIVER_PROCESSOR_TEXT_BUCKET=co-gcs-processor, the
  processor-text-reader credential WITH the empty SetCredential fallback
  (degrade, don't refuse), and ARCHIVER_PUBLIC_BASE_URL as its own line.
  Pinned to this unit alone; no ambient GOOGLE_APPLICATION_CREDENTIALS.
- The lifespan builds one reader after the gates, makes no request, warns
  once when a bucket has no usable key, closes it at shutdown, and says
  where links point.
- check_processor_text: one read through the service's reader; prints
  ok <bytes> or the reason, never the text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the reader

The variables table sits in a labelled, focusable .table-scroll. The
lifespan's reader announcement moves into diff_loader, where the bucket
variable is a local constant the outbound-env audit can resolve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ariables (D7)

- New entries: the Notifier gate's three variables and the dev pair
  (#368's held entry), ARCHIVER_PROCESSOR_TEXT_BUCKET,
  ARCHIVER_DIFF_MAX_INPUT_BYTES.
- ARCHIVER_PUBLIC_BASE_URL rewritten: the origin, /dashboard/info-items,
  the unit's own line.
- Room made by relocating four broker-side rationale paragraphs verbatim
  to BUS_CLIENT.md § The broker boundary (rules stay inline); 9981 -> 9619
  exact tokens. prove-no-loss: 0 lost; seams acknowledged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e loop

SandboxedEnvironment stops escapes and caps range, but 'x' * 10**9 still
allocated a gigabyte in the one process. Intercept * (100 000 items max)
and ** (exponent 100 max) on every render, and run the preview's render
and the test-send's compose in a worker thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he preview

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…page

src/api/routes/ changed, so the changelog trigger applies. Service-only
bump; the snapshot carries the version, SDK surface unchanged (5.16.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Notification composer, preview and test-send

1 participant