From 0e7edf4604c7b34d80f353ed2a86ed91c4b03b61 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Tue, 19 May 2026 04:55:29 -0400 Subject: [PATCH] docs(blog): align broker display surface --- README.md | 68 +++++++++----- docs/blog-shadow-preview.md | 6 +- docs/blog-staging.md | 33 ++++++- ...-lifecycle-architecture-spec-2026-04-27.md | 15 ++- ...and-pulse-public-data-policy-2026-04-27.md | 9 +- ...and-static-post-pulse-ingest-2026-05-10.md | 16 ++-- src/lib/pulse/load.test.ts | 92 +++++++++++++++++++ src/lib/pulse/load.ts | 60 ++++++++++-- src/routes/blog/+page.svelte | 26 +++++- src/routes/blog/[slug]/+page.svelte | 24 +++++ src/routes/pulse/+page.svelte | 82 ++++++++++++++++- 11 files changed, 377 insertions(+), 54 deletions(-) create mode 100644 src/lib/pulse/load.test.ts diff --git a/README.md b/README.md index 649ae5d..afacac1 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,16 @@ Hi! This is just my boring personal static blog ^w^ ## Build Chain +The build produces a static SvelteKit artifact. Tinyland snapshots and local +Markdown remain first-paint, no-JS, and regression fixtures; canonical blog and +Pulse display hydrates in the browser from the public Tinyland broker when it is +available. + ```mermaid flowchart LR Posts["src/posts Markdown"] --> Mdsvex["mdsvex"] - TinylandPosts["Tinyland post snapshot"] --> Ingest["ingest check"] - PulseJson["Pulse public snapshot"] --> PulseCheck["snapshot validator"] + TinylandPosts["Tinyland post snapshot fixture"] --> Ingest["fallback ingest check"] + PulseJson["Pulse public snapshot fixture"] --> PulseCheck["snapshot validator"] Static["static assets"] --> Images["image optimization"] Routes["SvelteKit routes"] --> Svelte["Svelte 5 compiler"] @@ -41,6 +46,11 @@ flowchart LR Adapter --> Build["build/"] Build --> Redirects["redirect pages"] Build --> Pagefind["Pagefind index"] + Build --> RuntimeHydration["browser runtime hydration"] + HubBlog["hub.tinyland.dev blog broker stream"] --> RuntimeHydration + HubPulse["hub.tinyland.dev Pulse public snapshot"] --> RuntimeHydration + RuntimeHydration --> Blog["/blog and /blog/[slug]"] + RuntimeHydration --> PulseRoute["/pulse"] CvTex["CV TeX"] --> Tectonic["Tectonic PDF workflow"] ``` @@ -95,40 +105,50 @@ Current boundary: this proves narrow public SvelteKit/Vite/Vitest, SvelteKit/Vit -## Content Automation +## Content Authority And Fallback Automation ```mermaid flowchart LR - SourceRepo["source repo blog/docs/posts"] --> Notify["repository_dispatch"] + Author["Jess edits greymatter in tinyland.dev"] --> Tinyland["tinyland.dev content authority"] + Tinyland --> HubStream["hub.tinyland.dev broker stream"] + HubStream --> RuntimeBlog["/blog runtime hydration"] + + Tinyland --> StaticSnapshots["checked snapshot fixtures"] + StaticSnapshots --> FirstPaint["static first paint and no-JS fallback"] + + SourceRepo["legacy source repo posts"] --> Notify["repository_dispatch"] Notify --> Collect["collect-posts workflow"] - Collect --> DraftPR["draft content PR"] - DraftPR --> Bot["blog-agent review"] - DraftPR --> DateGuard["future-date guard"] - Bot --> Human["review and edit"] - DateGuard --> Human - Human --> Schedule["scheduled label and PR body gate"] - Schedule --> AutoMerge["daily auto-merge check"] - AutoMerge --> Main["main"] + Collect --> DraftPR["draft fallback PR"] + DraftPR --> Human["review before merge"] ``` -## Federation Approach +Cross-repo collection is legacy/static intake for fallback content. It is not the +primary authoring path for Tinyland-managed posts. + +## Brokered Display And Federation Boundary ```mermaid flowchart TB - Tinyland["tinyland.dev projection authority"] --> PostSnapshot["reviewed post snapshot"] - Tinyland --> PulseSnapshot["public Pulse snapshot"] - Tinyland --> StreamDemo["AP-shaped stream demo"] - Tinyland --> Edge["projection-only public edge"] - Edge --> WebFinger["WebFinger and NodeInfo"] + TinylandEditor["tinyland.dev blog editor"] --> Greymatter["content/users/jesssullivan greymatter"] + Greymatter --> BlogBroker["hub.tinyland.dev blog broker stream"] + BlogBroker --> BlogRuntime["CF Pages /blog and /blog/[slug] runtime display"] + + PulseBroker["Tinyland Pulse broker/public policy"] --> PulseSnapshot["hub.tinyland.dev Pulse public snapshot"] + PulseSnapshot --> PulseRuntime["CF Pages /pulse runtime refresh"] + + StaticFixtures["checked-in snapshots and src/posts"] --> FirstPaint["static first paint/fallback"] + FirstPaint --> BlogRuntime + FirstPaint --> PulseRuntime - PostSnapshot --> IngestPosts["materialize checked posts"] - IngestPosts --> Blog["/blog"] + BlogBroker --> DisplayOnly["brokered display only"] + PulseSnapshot --> DisplayOnly + DisplayOnly --> NotFederation["not public Fediverse delivery"] - PulseSnapshot --> PulseRoute["/pulse"] - StreamDemo --> HiddenLab["/pulse/client/brokered-stream"] + ApLab["/pulse/client/brokered-stream"] --> ApDemo["AP-shaped hidden lab demo"] + ApDemo --> NotFederation - HiddenLab --> Boundary["projection demo only"] - Boundary --> NotFederation["not public Fediverse delivery"] + HubDiscovery["hub.tinyland.dev WebFinger and NodeInfo"] --> DiscoveryOnly["public discovery/projection metadata"] + DiscoveryOnly --> NotFederation ``` diff --git a/docs/blog-shadow-preview.md b/docs/blog-shadow-preview.md index b292c60..71822c4 100644 --- a/docs/blog-shadow-preview.md +++ b/docs/blog-shadow-preview.md @@ -68,8 +68,10 @@ uses the `tinyland-dind` ARC runner by default and accepts the same SvelteKit output and can publish it to Cloudflare Pages by Direct Upload. This is a shadow lane for moving `transscendsurvival.org` toward the Tinyland -static-spoke edge posture. It does not cut over DNS, replace GitHub Pages -production, or add live Tinyland broker fallback behavior. +static-spoke edge posture. It does not cut over DNS or replace GitHub Pages +production. The built site is still static, but current `/blog`, `/blog/[slug]`, +and `/pulse` client code may hydrate from public `hub.tinyland.dev` broker +endpoints at runtime when those endpoints are available. Required repository secrets: diff --git a/docs/blog-staging.md b/docs/blog-staging.md index da7aaa6..6331c3f 100644 --- a/docs/blog-staging.md +++ b/docs/blog-staging.md @@ -1,5 +1,21 @@ # Cross-Repo Blog Staging Pipeline +Status on 2026-05-19: this is a legacy/static intake path for fallback posts, +migration evidence, and source-repo drafts. It is not the primary authoring path +for the live blog. + +The primary content path is: + +```text +tinyland.dev blog editor / greymatter + -> hub.tinyland.dev broker stream + -> transscendsurvival.org /blog runtime hydration +``` + +Checked-in posts and snapshots remain useful for first paint, no-JS fallback, +search/index fixtures, and regression tests. New Tinyland-managed blog posts are +authored and edited in `tinyland.dev`, not in this repo's collector flow. + Blog posts can live in any repo. A collection pipeline pulls them into `jesssullivan.github.io`, normalizes frontmatter, rewrites links and images, and stages them as draft PRs for review before publication. @@ -26,6 +42,14 @@ Source repo push repository_dispatch Collector script ## Writing a Blog Post +For live Tinyland-managed posts, write or edit the post in the `tinyland.dev` +blog editor. The public site consumes the reviewed broker stream and must not +own mutation APIs, admin credentials, ActivityPub delivery workers, or media +lifecycle state. + +Use this collector flow only when intentionally staging a legacy/static fallback +post from another repository. + Put a markdown file in one of the scanned directories (`blog/`, `posts/`, or `docs/blog/`) in any configured source repo. @@ -50,11 +74,10 @@ linear_project: "Blog + Profile Integration" intended for the blog. If a file is already inside a scanned directory, the marker is optional. -If you are running the post through Tinyland's Linear surface, add -`linear_issue` and optionally `linear_project`. Keep the actual post body in -git-backed markdown, not in a Linear document. Linear is the control plane -for idea state, review state, and scheduling context — not the canonical -longform content store. +If you are running a legacy/static fallback post through Tinyland's Linear +surface, add `linear_issue` and optionally `linear_project`. Linear is the +control plane for idea state, review state, and scheduling context, not the +canonical longform content store. ### Image conventions diff --git a/docs/tinyland-pulse-lifecycle-architecture-spec-2026-04-27.md b/docs/tinyland-pulse-lifecycle-architecture-spec-2026-04-27.md index 6985f7b..1eed303 100644 --- a/docs/tinyland-pulse-lifecycle-architecture-spec-2026-04-27.md +++ b/docs/tinyland-pulse-lifecycle-architecture-spec-2026-04-27.md @@ -47,7 +47,10 @@ The core rule is simple: the tinyland broker owns the source of truth. The stati - Production photo upload and processing. - A production native/mobile app. - Public exposure of local git summaries. -- Runtime dependency from the static blog to a live tinyland broker. +- Runtime dependency from the static blog to a private Tinyland broker, + mutation API, queue, or event store. A browser fetch from the public + `hub.tinyland.dev` Pulse snapshot projection is allowed as a display refresh + after static first paint. ## System Shape @@ -58,7 +61,8 @@ client/demo composer -> queue/workflow boundary -> projection worker -> public snapshot + manifest - -> static blog /pulse + -> static blog /pulse first paint + -> hub.tinyland.dev public snapshot refresh optional later projections: -> ActivityStreams outbox mirror @@ -297,7 +301,10 @@ Build behavior: - `/pulse` remains `prerender = true`. - `npm run check` or a dedicated validation script fails on invalid snapshot shape. - `npm run build` must not require the broker to be online. -- A future optional fetch step may refresh the checked snapshot before build, but only behind an explicit operator command or CI input. +- Runtime browser hydration may refresh from the public hub snapshot endpoint + after first paint. Failure keeps the checked snapshot visible. +- A future optional fetch step may refresh the checked snapshot before build, + but only behind an explicit operator command or CI input. This keeps the static site fast and reviewable. @@ -332,7 +339,7 @@ M1 public projection must block: - generated git summaries - listening history - sensor readings -- live broker fetches +- private broker fetches, mutation APIs, and non-public event streams Photo support requires a separate media lifecycle: diff --git a/docs/tinyland-pulse-public-data-policy-2026-04-27.md b/docs/tinyland-pulse-public-data-policy-2026-04-27.md index 05b53c0..328fbd4 100644 --- a/docs/tinyland-pulse-public-data-policy-2026-04-27.md +++ b/docs/tinyland-pulse-public-data-policy-2026-04-27.md @@ -30,13 +30,18 @@ These remain blocked until they have their own policy version. Adding one of the - Generated git summaries. Require an explicit per-repository allowlist and a redaction layer for paths and committer information. - Listening history. Require either explicit user-facing opt-in per item or a heavy aggregation step that strips temporal granularity. - Sensor readings. Require an explicit decision about which environmental contexts can be inferred from the data. -- Live broker fetches at render time. The static blog must always read a checked or generated snapshot, never the live broker. +- Private broker fetches, mutation APIs, or non-public event streams at render + time. The static blog may only hydrate from the public + `hub.tinyland.dev/projections/jesssullivan-github-io/pulse/public-snapshot.v1.json` + projection after first paint, and it must keep the checked snapshot as a + fallback. ## ActivityPub language guidelines The blog has shipped WebFinger discovery only ([PR #68](https://github.com/Jesssullivan/jesssullivan.github.io/pull/68)). It has not shipped real ActivityPub federation. Until it does, PRs and posts should use the following terms: -- "WebFinger discovery" - what is currently live: `acct:jess@transscendsurvival.org` resolves to `https://tinyland.dev/@jesssullivan` via `.well-known/webfinger`. +- "WebFinger discovery" - public discovery metadata only. The canonical public + broker actor handle is `acct:jesssullivan@hub.tinyland.dev`. - "AP-shaped mirror" or "ActivityStreams projection" - any future static `outbox.json` or actor document that is not actually delivered to remote servers. This is a publication shape, not a federation. - "ActivityPub federation" or "federated" - reserved for the day the broker speaks server-to-server: actor lifecycle, signed delivery, follower collection, inbox handling, retries, updates/deletes, tombstones, moderation, and compatibility testing against real servers. diff --git a/docs/tinyland-static-post-pulse-ingest-2026-05-10.md b/docs/tinyland-static-post-pulse-ingest-2026-05-10.md index 5a18062..7df67b5 100644 --- a/docs/tinyland-static-post-pulse-ingest-2026-05-10.md +++ b/docs/tinyland-static-post-pulse-ingest-2026-05-10.md @@ -7,9 +7,9 @@ reviewed source projection. 2026-05-19 correction: this checked-in ingest path is fallback and migration evidence only. The intended Cloudflare Pages display path is runtime broker -fetch from `hub.tinyland.dev`, with Tinyland-managed greymatter as the source of -truth. Checked-in snapshots must not be treated as the live blog federation -mechanism. +fetch from `hub.tinyland.dev`, with Tinyland-managed greymatter and Pulse policy +snapshots as the source of truth. Checked-in snapshots must not be treated as +the live blog federation mechanism. ## Checked-In Inputs @@ -26,11 +26,12 @@ static/data/pulse/public-snapshot.v1.json ``` Both are copied from Tinyland reviewed static artifacts. They remain useful as -first-paint and regression fixtures, but canonical blog display now hydrates -from: +first-paint and regression fixtures, but canonical display now hydrates from the +public broker endpoints when available: ```text https://hub.tinyland.dev/projections/jesssullivan-github-io/blog/broker-stream.v1.json +https://hub.tinyland.dev/projections/jesssullivan-github-io/pulse/public-snapshot.v1.json ``` ## Post Ingest @@ -98,8 +99,9 @@ Allowed: - checked-in static snapshots as fallback/regression fixtures; - ordinary Markdown/frontmatter posts in `src/posts` for legacy/static first-paint content; -- runtime display fetches from the public `hub.tinyland.dev` broker stream; -- the existing `PublicPulseSnapshot` validator and `/pulse` renderer. +- runtime display fetches from public `hub.tinyland.dev` broker endpoints; +- the existing `PublicPulseSnapshot` validator and `/pulse` first-paint + renderer. Blocked: diff --git a/src/lib/pulse/load.test.ts b/src/lib/pulse/load.test.ts new file mode 100644 index 0000000..35e817f --- /dev/null +++ b/src/lib/pulse/load.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + PUBLIC_SNAPSHOT_PATH, + TINYLAND_PULSE_PUBLIC_SNAPSHOT_URL, + loadPulsePublicBrokerSnapshot, + loadPulseSnapshot, + type PulseSnapshotFetch, +} from './load'; +import type { PublicPulseSnapshot } from '@blog/pulse-core/schema'; + +const validSnapshot: PublicPulseSnapshot = { + schemaVersion: 'tinyland.pulse.v1.PublicPulseSnapshot', + generatedAt: '2026-05-10T13:00:00.000Z', + items: [ + { + id: 'tinyland-pulse-note-2026-05-10-001', + kind: 'note', + occurredAt: '2026-05-10T12:30:00.000Z', + summary: 'Live hello from Tinyland', + content: 'Live hello from Tinyland', + tags: ['tinyland', 'pulse'], + }, + ], + manifest: { + schemaVersion: 'tinyland.pulse.v1.PublicPulseSnapshot', + generatedAt: '2026-05-10T13:00:00.000Z', + sourceSnapshotId: 'tinyland-jesssullivan-pulse-static-seed-2026-05-10', + contentHash: 'sha256:6a0552b5648f3e80f3f17edd104d7d1389c034abcdaf981651af56929fbfd44e', + itemCount: 1, + policyVersion: 'm1-2026-04-27', + }, +}; + +const jsonResponse = (body: unknown, init: ResponseInit = {}) => + new Response(JSON.stringify(body), { + status: init.status ?? 200, + statusText: init.statusText, + headers: { 'Content-Type': 'application/json' }, + }); + +describe('loadPulseSnapshot', () => { + it('loads the checked-in static Pulse snapshot for first paint', async () => { + const fetchMock = vi.fn(async () => jsonResponse(validSnapshot)); + + await expect(loadPulseSnapshot(fetchMock)).resolves.toEqual(validSnapshot); + expect(fetchMock).toHaveBeenCalledWith(PUBLIC_SNAPSHOT_PATH); + }); +}); + +describe('loadPulsePublicBrokerSnapshot', () => { + it('fetches the hub broker snapshot without falling back to the checked-in file', async () => { + const fetchMock = vi.fn(async () => jsonResponse(validSnapshot)); + + await expect(loadPulsePublicBrokerSnapshot(fetchMock)).resolves.toEqual(validSnapshot); + expect(fetchMock).toHaveBeenCalledWith( + TINYLAND_PULSE_PUBLIC_SNAPSHOT_URL, + expect.objectContaining({ + headers: { Accept: 'application/json' }, + cache: 'no-store', + }), + ); + expect(fetchMock.mock.calls.flat().join(' ')).not.toContain(PUBLIC_SNAPSHOT_PATH); + }); + + it('rejects invalid broker snapshots instead of rendering unchecked data', async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ + ...validSnapshot, + items: validSnapshot.items.map((item) => ({ + ...item, + latitude: 42.44, + longitude: -76.5, + })), + }), + ); + + await expect(loadPulsePublicBrokerSnapshot(fetchMock)).rejects.toThrow( + 'pulse snapshot failed schema validation', + ); + }); + + it('fails closed when the broker endpoint is unavailable', async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ error: 'unavailable' }, { status: 503, statusText: 'Service Unavailable' }), + ); + + await expect(loadPulsePublicBrokerSnapshot(fetchMock)).rejects.toThrow( + 'pulse broker snapshot fetch failed: 503', + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/pulse/load.ts b/src/lib/pulse/load.ts index 8b39937..3bb01eb 100644 --- a/src/lib/pulse/load.ts +++ b/src/lib/pulse/load.ts @@ -1,16 +1,64 @@ import { PublicPulseSnapshotSchema, type PublicPulseSnapshot } from '@blog/pulse-core/schema'; export const PUBLIC_SNAPSHOT_PATH = '/data/pulse/public-snapshot.v1.json'; +export const TINYLAND_PULSE_PUBLIC_SNAPSHOT_URL = + 'https://hub.tinyland.dev/projections/jesssullivan-github-io/pulse/public-snapshot.v1.json'; -export async function loadPulseSnapshot(fetchFn: typeof fetch): Promise { +export type PulseSnapshotFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +export interface PulseSnapshotFetchOptions { + readonly endpoint?: string; + readonly signal?: AbortSignal; +} + +function parsePulseSnapshot(data: unknown, source: string): PublicPulseSnapshot { + const result = PublicPulseSnapshotSchema.safeParse(data); + if (!result.success) { + throw new Error(`pulse snapshot failed schema validation from ${source}: ${result.error.issues.map((i) => i.message).join('; ')}`); + } + + return result.data; +} + +export async function loadPulseSnapshot(fetchFn: PulseSnapshotFetch): Promise { const res = await fetchFn(PUBLIC_SNAPSHOT_PATH); if (!res.ok) { throw new Error(`pulse snapshot fetch failed: ${res.status} ${res.statusText} (${PUBLIC_SNAPSHOT_PATH})`); } - const data: unknown = await res.json(); - const result = PublicPulseSnapshotSchema.safeParse(data); - if (!result.success) { - throw new Error(`pulse snapshot failed schema validation: ${result.error.issues.map((i) => i.message).join('; ')}`); + + return parsePulseSnapshot(await res.json(), PUBLIC_SNAPSHOT_PATH); +} + +export async function loadPulsePublicBrokerSnapshot( + fetchFn: PulseSnapshotFetch, + options: PulseSnapshotFetchOptions = {}, +): Promise { + const endpoint = options.endpoint ?? TINYLAND_PULSE_PUBLIC_SNAPSHOT_URL; + const init: RequestInit = { + headers: { Accept: 'application/json' }, + cache: 'no-store', + }; + + if (options.signal) { + init.signal = options.signal; } - return result.data; + + const res = await fetchFn(endpoint, init); + if (!res.ok) { + throw new Error(`pulse broker snapshot fetch failed: ${res.status} ${res.statusText} (${endpoint})`); + } + + return parsePulseSnapshot(await res.json(), endpoint); +} + +export function summarizePulseSnapshotError(error: unknown): string { + if (error instanceof Error) { + if (error.name === 'AbortError') { + return 'broker request timed out'; + } + + return error.message; + } + + return 'broker request failed'; } diff --git a/src/routes/blog/+page.svelte b/src/routes/blog/+page.svelte index 3043ec5..aec3a9b 100644 --- a/src/routes/blog/+page.svelte +++ b/src/routes/blog/+page.svelte @@ -47,6 +47,20 @@ // Recent 5 posts for sidebar let recentPosts = $derived(displayPosts.slice(0, 5)); + let brokerStatusLabel = $derived( + brokerState.status === 'ready' + ? `Updated ${formatTimestamp(brokerState.stream.generatedAt)}` + : brokerState.status === 'unavailable' + ? 'Static snapshot may be stale' + : 'Checking broker', + ); + + function formatTimestamp(value: string): string { + return new Date(value).toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }); + } onMount(() => { let cancelled = false; @@ -110,9 +124,17 @@ {/if} -
+

Blog

- {displayPosts.length} posts +
+

{displayPosts.length} posts

+

+ {brokerStatusLabel} +

+
diff --git a/src/routes/blog/[slug]/+page.svelte b/src/routes/blog/[slug]/+page.svelte index b2835f3..436fd0c 100644 --- a/src/routes/blog/[slug]/+page.svelte +++ b/src/routes/blog/[slug]/+page.svelte @@ -56,6 +56,15 @@ let activeImageUrl = $derived(resolveSiteImageUrl(activeMetadata.feature_image)); let activeOriginalUrl = $derived(activeMetadata.original_url); let activeOriginalHost = $derived(activeOriginalUrl ? new URL(activeOriginalUrl).hostname : ''); + let brokerStatusLabel = $derived( + brokerStatus === 'ready' && brokerPost + ? `Updated ${formatTimestamp(brokerPost.updatedAt)}` + : brokerStatus === 'unavailable' && !data.brokerOnly + ? 'Static snapshot may be stale' + : brokerStatus === 'loading' + ? 'Checking broker' + : '', + ); function resolveSiteImageUrl(value: string | undefined): string { if (!value) return 'https://transscendsurvival.org/images/header.png'; @@ -64,6 +73,13 @@ return `https://transscendsurvival.org${path}`; } + function formatTimestamp(value: string): string { + return new Date(value).toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }); + } + function updateReadingProgress() { const article = document.querySelector('article'); if (!article) return; @@ -278,6 +294,14 @@ {/each}
{/if} + {#if brokerStatusLabel} +

+ {brokerStatusLabel} +

+ {/if}
diff --git a/src/routes/pulse/+page.svelte b/src/routes/pulse/+page.svelte index 4e3eebc..2e54c2c 100644 --- a/src/routes/pulse/+page.svelte +++ b/src/routes/pulse/+page.svelte @@ -1,8 +1,68 @@ @@ -11,13 +71,31 @@
+
+ {#if brokerStatus === 'ready'} + Tinyland broker pulse snapshot loaded. + {:else if brokerStatus === 'stale'} + Tinyland broker pulse snapshot unavailable: {brokerUnavailableReason} + {:else} + Tinyland broker pulse snapshot loading. + {/if} +
+
-

Pulse

+
+

Pulse

+

+ {statusLabel} +

+

A small public-safe stream of notes and bird sightings, projected from the tinyland broker under the M1 public-data policy.

- +