Skip to main content

AW-18 — Implementation Plan: Shared read-only views & channel guests

Translates spec.md into architecture, data model and phasing. The plan owns implementation detail; the spec owns behaviour (Constitution IX).

Feature ID: aw-18-shared-dashboards Spec: ./spec.md · Tasks: ./tasks.md Status: Draft Last updated: 2026-09-06 Blocking dependency: AW-02 Task board — the Focus column projection this epic publishes.


1. Current state in the codebase

Everything below was read in this repository. Paths are relative to the monorepo root.

1.1 Organizations, Tenants and who "the owner" is

What existsWhereWhat it means for this epic
Tenant entity, ownerUserId unique 1:1 with Userpackages/agent/src/entities/tenant.entity.tsThis is the only unambiguous "owner" the platform has. Every owner-only check in this epic resolves organization.tenantId → tenant.ownerUserId === currentUser.id.
Organization entitypackages/agent/src/entities/organization.entity.tsThe Workspace a Shared view hangs off.
OrganizationMember — roster only, role persisted but explicitly not an authorization inputpackages/agent/src/entities/organization-member.entity.tsWhy we cannot express "only admins may publish" today, and why we resolve the Tenant owner instead of inventing a role.
OrganizationOwnershipGuard (member-level; @OrgAdmin() currently identical to member)apps/api/src/organizations/guards/organization-ownership.guard.ts, apps/api/src/organizations/organization-membership.service.tsReused unchanged for membership. A new, narrower owner guard sits on top of it for the write routes.
Scope resolution (ScopeContext, X-Scope-Slug)apps/api/src/scope/The owner-facing endpoints resolve the active Organization exactly as every other Tier-A read does.

1.2 The token/hash pattern this epic copies

packages/agent/src/entities/organization-invitation.entity.ts already implements 256-bit token → sha256 tokenHash with a unique index, the raw token never stored, and the consumption side split into a @Public() preview plus an authenticated accept (apps/api/src/onboarding/org-invite.controller.ts). The one deliberate divergence here: a share link must be re-copyable, so the token is also stored envelope-encrypted using the existing EncryptedJsonColumn helper (AES-256-GCM, enc::v1:: prefix — the same mechanism notification_channels.targetConfig uses). Lookup is still by hash; decryption happens only on the owner's own settings read.

The same precedent settles the transport. OrgInviteController.preview is a POST even though it is a read, and its comment says why: a token in the URL is persisted by apps/api/src/logging.interceptor.ts, which logs Incoming Request: ${method} ${originalUrl} unredacted, and by Sentry, which attaches the request URL. The same is true of the two interceptors registered beside it in apps/api/src/api.module.ts: SentryInterceptor puts originalUrl into the request context, the transaction tag and the endpoint tag, and PostHogInterceptor strips only the query string, so a path segment reaches the endpoint property intact. A request body field named token reaches none of them — SentryInterceptor.SENSITIVE_BODY_KEYS already drops it — and an Authorization header is deleted by sanitizeHeaders. §4.2 is built on exactly those two facts.

1.3 The public-route precedent in the web app

1.4 The inbound chat path the admission gate plugs into

Slack delivery ─► apps/api/src/ingest/slack/slack-events.controller.ts (@Public, signature-verified)
│ slack-commands.controller.ts

SlackChatBridgeService apps/api/src/ingest/slack/slack-chat-bridge.service.ts
│ resolves the OWNER via IngestInstallBinding

OpenAiCompatService.handleCompletion apps/api/src/ai-conversation/openai-compat.service.ts

▼ reply posted back through the slack-connector plugin
  • packages/agent/src/entities/ingest-install-binding.entity.ts is the per-external-workspace → platform-user binding. Its own docblock states the invariant this epic depends on: rows are written by the server only, and only after a delivery has passed signature verification — the binding is a record of proven ownership, never a user claim. That is why the allowlist hangs off a binding and why the owner cannot type an external workspace id into a form (spec S-20, FR-51).
  • The binding's provider column is an unconstrained varchar(32), so telegram / discord widen it with no schema change when their inbound legs land.
  • Today the bridge admits any sender the signature validates. That is the exact line the gate is inserted on.

1.5 Connections (connected chat channels)

1.6 Decisions

1.7 Knowledge Base

1.8 Activity log and notifications

1.9 Untrusted-text fencing (for FR-65)

1.10 Background work

1.11 Migrations

174 timestamp-prefixed files live in apps/api/src/migrations/; the newest on develop at time of writing is 1790100000000-AddReleaseVerification.ts. New entities must also be registered in packages/agent/src/database/_entities-inventory.ts and _entity-names.ts — this repo has no autoLoadEntities, so a forFeature'd-but-unregistered entity throws EntityMetadataNotFoundError on first query, and a drift spec fails CI otherwise.


2. Architecture and the seam

2.1 Two independent slices, one epic

2.2 The seam for slice A — a projection service, not a second read model

The published board must never disagree with the private board (FR-15). It therefore reads the same repository query the private Task board uses for its Focus layout (delivered by AW-02 — four columns grouping the seven TaskStatus values) and passes the rows through a publish filter that is a pure function:

TaskRow[] ──► publishTaskCard(row) ──► PublishedTaskCard

└── drops every field not on the FR-14 allowlist

Two rules keep this honest and testable:

  1. PublishedTaskCard, PublishedAgent, PublishedActivityLine and PublishedDocument are closed DTO types in packages/contracts with no index signature and no passthrough. A field that is not declared cannot be serialised.
  2. The publish filters are pure functions with their own unit specs that assert the exact key set of the output object. Adding a field to Task cannot leak it, because the key-set assertion fails first. In particular the Task's owner columns (missionId, workId, ideaId, teamId, agentId, goalId) and the Mission provenance chip the private card renders from them are not on the allowlist (FR-21).

The same rule governs the activity strip: PUBLISHABLE_ACTIVITY_ACTIONS is a frozen allowlist constant, and a spec asserts that every member of ActivityActionType is either on it or on an explicit NEVER_PUBLISH list — so a newly added action type fails CI until somebody classifies it (FR-20).

2.3 The seam for slice B — one gate, in front of everything

ChannelGuestAdmissionService.authorize(event, ctx) implements the ConnectorPairingAuthorizer signature already declared in packages/plugin/src/contracts/capabilities/connector.interface.ts, returning ConnectorAuthorizationDecision. It is called from SlackChatBridgeService after signature verification and binding resolution and before any call into OpenAiCompatService. It reads two tables and a rate-limit bucket; it never calls a facade, so a denied message costs zero model tokens (FR-61, NFR "Cost").

When the connector inbound runtime lands (the connectors epic, P2), it binds this same service to its ConnectorPairingAuthorizer slot. No second gate is written.

2.4 Owner resolution — one helper, one place

SharedViewOwnerGuard resolves organizationId → tenantId → tenant.ownerUserId and throws NotFoundException (never ForbiddenException) on a miss, matching the 404-never-403 convention this area uses uniformly. It composes after OrganizationOwnershipGuard so a non-member is rejected by the existing guard first and never reaches the owner lookup. When per-Organization roles land, this guard is the one place that changes.


3. Data model

3.1 New entity — SharedView

packages/agent/src/entities/shared-view.entity.ts, table shared_views.

ColumnTypeNotes
iduuid PK
organizationIduuid, unique, FK → organizations ON DELETE CASCADEFR-1 (one per Workspace), FR-5 (cascade)
tenantIduuid, indexedTier-A scope column, copied at creation
ownerUserIduuid, FK → usersDenormalised for the public read path so it never joins to tenants
tokenHashvarchar(64), unique indexsha256(token), the public lookup key
tokenEncryptedtext, EncryptedJsonColumn()The re-copyable token, owner-read only (FR-7)
statusvarchar(16), default 'active'active | paused (FR-10)
sectionsjsonb, default {"board":true,"knowledge":false}FR-13, FR-25
knowledgeClassesjsonb string[], default []FR-26, FR-27 — empty fails closed
searchIndexableboolean, default falseFR-34
viewCountinteger, default 0FR-44
lastViewedAttimestamptz, nullableFR-44
firstViewNotifiedAttimestamptz, nullableFR-46 — reset to NULL on regenerate
tokenRotatedAttimestamptz, nullableFR-48
rotationCountinteger, default 0FR-48
createdByIduuid FK → usersaudit
createdAt / updatedAttimestamptz

Indexes: UNIQUE(organizationId), UNIQUE(tokenHash), INDEX(tenantId).

Why tokenEncrypted as well as tokenHash. An invitation token is shown once and then consumed; a share link is pasted, re-pasted and re-copied for months. Storing only the hash would force a regenerate — which kills every outstanding copy — every time the owner needs the link again. Encrypted-at-rest with an owner-only decrypt is the Constitution VII-compliant way to keep both properties.

3.2 New entity — ChannelGuest

packages/agent/src/entities/channel-guest.entity.ts, table channel_guests.

ColumnTypeNotes
iduuid PK
bindingIduuid, FK → ingest_install_bindings ON DELETE CASCADEThe proven-ownership record the allowlist hangs off (FR-51)
ownerUserIduuid, FK → users, indexedDenormalised from the binding for the gate's single-row lookup
tenantId / organizationIduuid, nullable, indexedTier-C scope columns
externalUserIdvarchar(128)Exact identifier on the external service
externalUserHandlevarchar(128), nullableCaptured from the first admitted delivery, display only
displayNamevarchar(64)Owner-typed (FR-54)
notevarchar(200), nullable
statusvarchar(16), default 'active'active | revoked
admittedAttimestamptz
lastSeenAttimestamptz, nullable
requestCountinteger, default 0
revokedAttimestamptz, nullable
createdByIduuid FK → users
createdAt / updatedAttimestamptz

Indexes: UNIQUE(bindingId, externalUserId) (FR-58 — the same identity may exist on another binding), INDEX(ownerUserId, status), INDEX(bindingId, externalUserId, status) — the gate's hot path is one indexed row.

3.3 Additive columns on existing tables

All nullable, all safe on rollback (Constitution X).

TableColumnTypeWhy
tasksrequestedByGuestIduuid, nullable, FK → channel_guests ON DELETE SET NULLFR-68 — the primary case: a guest's request produces Tasks
tasksrequestedByLabelvarchar(160), nullableFR-67, FR-73 — retained verbatim after revoke
missionsrequestedByGuestIduuid, nullable, FK → channel_guests ON DELETE SET NULLFR-68 — only for the case where the Run sets up a standing initiative at the guest's request
missionsrequestedByLabelvarchar(160), nullableFR-68, FR-73
agent_action_proposalsrequestedByGuestIduuid, nullable, FK SET NULLFR-69
agent_action_proposalsrequestedByLabelvarchar(160), nullableFR-69
agent_escalationsrequestedByGuestIduuid, nullable, FK SET NULLFR-69
agent_escalationsrequestedByLabelvarchar(160), nullableFR-69
agent_escalationsoriginConversationRefvarchar(256), nullableWhere to post the outcome back (FR-78)
agent_action_proposalsoriginConversationRefvarchar(256), nullableSame
work_knowledge_documentssharedViewExcludedboolean, default falseFR-28, P3 only

requestedByLabel is denormalised on purpose. FR-73 requires historical attribution to survive a revoke and a rename; a join to channel_guests would rewrite history.

3.4 Enum additions (no migration required)

ActivityActionType in packages/agent/src/entities/activity-log.types.ts is a TypeScript enum over a free varchar(50) column — appended members need no schema change:

SHARED_VIEW_ENABLED = 'shared_view_enabled'
SHARED_VIEW_DISABLED = 'shared_view_disabled'
SHARED_VIEW_REGENERATED = 'shared_view_regenerated'
SHARED_VIEW_SECTIONS_CHANGED = 'shared_view_sections_changed'
SHARED_VIEW_INDEXING_CHANGED = 'shared_view_indexing_changed'
CHANNEL_GUEST_ADDED = 'channel_guest_added'
CHANNEL_GUEST_RENAMED = 'channel_guest_renamed'
CHANNEL_GUEST_REVOKED = 'channel_guest_revoked'
CHANNEL_GUEST_ADMITTED = 'channel_guest_admitted'
CHANNEL_GUEST_DENIED = 'channel_guest_denied'
CHANNEL_GUEST_THROTTLED = 'channel_guest_throttled'

shared_view_viewed is deliberately not an activity kind — a per-view row would flood the feed. Views are a counter on the row (FR-44).

3.5 Migrations — forward-only, same PR (Constitution V)

Timestamps are AW-18's slots of the program's reserved migration blocks (README §5 rule 10), in apply order; re-stamp before merge if develop has moved past them.

File (in apps/api/src/migrations/)ContentsPhase
1791180000000-CreateSharedViews.tsCREATE TABLE shared_views + 3 indexesP1
1791180100000-CreateChannelGuests.tsCREATE TABLE channel_guests + 3 indexesP2
1791180200000-AddRequesterAttribution.ts10 nullable columns across tasks, missions, agent_action_proposals, agent_escalations + FKs ON DELETE SET NULLP2
1791180300000-AddKbSharedViewExcluded.tswork_knowledge_documents.shared_view_excluded boolean NOT NULL DEFAULT falseP3

Every down() is a plain DROP/DROP COLUMN of only what its up() added. No existing column is altered, renamed or dropped anywhere in this epic.

3.6 Contracts

New DTOs under packages/contracts/src/api/shared-view/, exported from that folder's index.ts and re-exported from packages/contracts/src/api/index.ts:

  • SharedViewSettingsDto (owner read/write — carries the decrypted token only on the owner read)
  • SharedViewSectionsDto, SharedViewIndexingMode
  • PublishedBoardDto{ workspaceName, columns: PublishedColumnDto[], agents: PublishedAgentDto[], recent: PublishedActivityLineDto[], generatedAt }
  • PublishedTaskCardDto, PublishedAgentDto, PublishedActivityLineDto
  • PublishedDocumentSummaryDto, PublishedDocumentDto
  • ChannelGuestDto, CreateChannelGuestDto, UpdateChannelGuestDto
  • PUBLISHABLE_ACTIVITY_ACTIONS frozen constant

All published DTOs are closed — no index signatures, no Record<string, unknown> escape hatch (§2.2).


4. API surface

4.1 Owner-facing — apps/api/src/shared-views/shared-views.controller.ts

@Controller('api/organizations/:orgId/shared-view'), guarded by AuthSessionGuard + OrganizationOwnershipGuard (class level) + SharedViewOwnerGuard (on every write and on the token-bearing read).

MethodPathBody / queryAuthNotes
GET/memberSettings + counters. link present only for the Tenant owner (FR-4).
POST/ownerCreate + activate. 201 with the link. Idempotent: re-POST on an existing row returns the current row, 200. Throttle 10/min.
POST/regenerateownerNew token, firstViewNotifiedAt := NULL, rotationCount += 1. Throttle 10/min (FR-9).
PATCH/{ status?, sections?, knowledgeClasses?, searchIndexable? }ownerOne write per changed facet → one activity row each (FR-47). Throttle 30/min.
DELETE/ownerHard-deletes the row; the link dies. Distinct from PATCH {status:'paused'}.
GET/preview?section=board|knowledgeownerRuns the public projection under the owner's session (FR-45 — no counter).
GET/knowledge-classesownerPer-class publishable document counts for the confirm dialog (FR-33).

4.2 Public — apps/api/src/shared-views/shared-view-public.controller.ts

@Controller('api/public/shared-view'), @Public(), no user session, no cookie. No route takes the token in its path or query string (spec FR-7a). The token is exchanged once, in a body, for a short-lived view session; every read presents only that.

MethodPathBody / headerNotes
POST/sessionsbody { token }Resolves the token by hash; 200 { viewSession, expiresAt }. Unknown / rotated / paused → the identical "no longer active" 404 (FR-11). Throttle 60/min per token hash + 600/hour per client (FR-42). A POST for the reason OrgInviteController.preview gives (§1.2).
GET/boardAuthorization: Bearer <viewSession>PublishedBoardDto. Throttle 60/min per Shared view (FR-42).
GET/knowledgeAuthorization: Bearer <viewSession>; ?q=&cursor=PublishedDocumentSummaryDto[]; q min 2 chars, page 50, cap 200 (FR-30).
GET/knowledge/:docIdAuthorization: Bearer <viewSession>PublishedDocumentDto; 404 if class deselected (FR-32) or excluded.

The view sessionapps/api/src/shared-views/shared-view-session.service.ts, modelled on TerminalAttachService's compact HMAC token:

  • Format base64url(claims).base64url(HMAC-SHA256), claims { v: 1, sid: sharedViewId, rot: rotationCount, exp }, TTL 15 minutes. It carries no token, no token hash, no Organization id and no user id.
  • Secret SHARED_VIEW_SESSION_SECRET, falling back to BETTER_AUTH_SECRET / AUTH_SECRET exactly as the terminal attach token does; fail closed — with no secret, minting is a 503 and verification refuses everything.
  • Revocable without storage. A guard verifies the MAC (timingSafeEqual) and exp, then loads the row by sid and requires status = 'active' and rotationCount = rot. Regenerate already increments rotationCount (§3.1), so every outstanding view session dies on its next request with zero grace (FR-8); pausing (FR-10) and deleting (FR-5) kill them the same way. Every refusal is the identical FR-11 response. No table, no migration.
  • Renewal. The client re-exchanges when expiresAt is under 60 s away or a read answers the FR-11 response, by posting the token again in a body. A failed renewal renders the not-active state.
  • View counting (FR-44) and the first-view notification (FR-46) are driven from the exchange, not from each read, so a 20 s poll never inflates the count.

Redaction as defence in depth (spec FR-7b) — packages/monitoring/src/redaction/secret-url.ts exports redactSecretUrl(url) and redactSecretValue(text), which replace a 43-character share token after a /share/ path segment, any Bearer view session, and any body token value with [redacted]. They are applied at every recorder, not left to downstream filters:

  • apps/api/src/logging.interceptor.ts — both the request and the response/error lines log redactSecretUrl(originalUrl) instead of originalUrl.
  • SentryInterceptor — the request-context url, the transaction tag and the endpoint tag; sentry.config.ts beforeSend / beforeSendTransactionevent.request.url, the transaction name and every breadcrumb data.url.
  • PostHogInterceptor — the endpoint property.
  • API error context — every error the public controller throws, and any message or URL an APP_FILTER exception filter under apps/api/src/common/filters/ logs, go through redactSecretValue; no share-link error message ever interpolates the token or the view session.
  • Web — apps/web/src/components/posthog/PostHogProvider.tsx skips posthog.init and the page-view capture when the pathname is a share route (spec FR-40), and adds a sanitize_properties hook applying redactSecretUrl to $current_url, $pathname and $referrer in case a share URL is ever captured from another page.

What still carries the token, and why that is acceptable. The visitor-facing page address /share/<token> is the link, as the invitation link is for /org-invite/[token]. That request line reaches the web app, which does not log request lines; the page sets Referrer-Policy: no-referrer (FR-39) and loads no analytics (above). The edge access log in front of the web host is outside the application: T14b's Done-when requires confirming that its log format drops or redacts /share/ paths before P1 ships.

Response headers on every public route, set by a dedicated interceptor:

Cache-Control: no-store
Referrer-Policy: no-referrer
X-Robots-Tag: noindex, nofollow, noarchive, nosnippet ← omitted when searchIndexable
X-Content-Type-Options: nosniff

Throttling uses two @Throttle buckets — one keyed on the token hash (60/min), one on the client (600/hour) — and runs before the projection query (NFR "Throughput"). 429 carries Retry-After: 60.

4.3 Channel guests — apps/api/src/channel-guests/channel-guests.controller.ts

@Controller('api/connections/:connectionId/guests'), AuthSessionGuard + ConnectionOwnerGuard (resolves the Connection → its binding → the Tenant owner). 404-never-403 throughout.

MethodPathBodyNotes
GET/List + { used, max } counters. Returns { bindingReady: false } when no verified binding exists yet (S-20).
POST/{ externalUserId, displayName, note? }409 on duplicate, 422 over the 25/100 caps. Throttle 20/min.
PATCH/:guestId{ displayName?, note?, status? }Rename or revoke. Throttle 30/min.
DELETE/:guestIdHard delete; historical labels survive (§3.3).

4.4 Web BFF proxies

Owner-side reads/writes go through server actions (§5.2). The public page calls the API directly from its server component — it must never touch a Next.js route handler that could accidentally read the session cookie. The server component performs the POST /sessions exchange with the token in the body, renders the first view with the resulting view session, and passes only { viewSession, expiresAt } to the client components; the 20 s poll sends the view session in the Authorization header and never builds an API URL from the token.


5. Web layer

5.1 New routes and files

PathKindNotes
apps/web/src/app/[locale]/share/[token]/page.tsxServer componentThe published page. Sibling of org-invite/, so the static share segment wins over [slug]. Exchanges the token for a view session in a request body (§4.2, §4.4) and renders board + knowledge tabs with it; no client JS required for first paint (FR-85).
apps/web/src/app/[locale]/share/[token]/not-active.tsxServer componentThe identical "no longer active" body used by every failure (FR-11).
apps/web/src/components/share/PublishedBoard.tsxClientColumns, cards, roster, strip; 20 s poll with visibility + idle handling (FR-43).
apps/web/src/components/share/PublishedKnowledge.tsxClientTwo-pane list/reader with debounced search.
apps/web/src/components/share/PublishedShell.tsxClientTabs, footer, live region, keyboard map (§6.12 of the spec).
apps/web/src/app/robots.tsMetadata routeUnchanged by this epic. /robots.txt is site-wide and cannot vary per Shared view, so it must not depend on searchIndexable nor Disallow: /share/ (a disallowed URL is never fetched, so its noindex header would never be read). The per-view directive is X-Robots-Tag plus the page robots meta (FR-35).
apps/web/src/app/[locale]/(dashboard)/settings/sharing/page.tsxServer componentSettings → Sharing.
apps/web/src/components/settings/SharingSettings.tsxClientLink card, section toggles, class picker, indexing radio, confirm dialogs.
apps/web/src/components/settings/ChannelGuestsPanel.tsxClientMounts inside NotificationChannelsSettings.tsx per channel.
apps/web/src/app/actions/shared-view.tsServer actionsgetSharedView, createSharedView, regenerateSharedViewLink, updateSharedView, deleteSharedView, getKnowledgeClassCounts.
apps/web/src/app/actions/channel-guests.tsServer actionslistChannelGuests, addChannelGuest, updateChannelGuest, deleteChannelGuest.

5.2 Wiring into the existing shell

5.3 State and data fetching

SurfaceFetchCadence
Published boardServer component does the first render; a client poll replaces the payload20 s; paused on document.hidden; stopped after 30 min idle
Published knowledge listServer component; client search re-queries300 ms debounce
Sharing settingsServer component + server actions with revalidatePathon action
Guests panelServer action list + optimistic add/revoke, reconciled on responseon action

The poll is a plain fetch against the public endpoint with cache: 'no-store'; on a 429 the client backs off to 60 s and surfaces the throttled copy; on a network error it keeps the last good render and shows "couldn't refresh" (NFR "Availability").


6. Background work (Constitution IV)

Both jobs are dispatched through a *_DISPATCHER DI symbol registered in packages/agent/src/tasks/_tasks-symbols.ts and bound by buildJobRuntimeProviders() in packages/tasks/src/trigger/trigger.module.ts. No call site imports a job-runtime SDK directly. Each symbol ships as a <name>-dispatcher.ts + <name>.types.ts pair under packages/agent/src/tasks/, matching the seven kb-* dispatchers already there; the task implementation lives under packages/tasks/src/tasks/trigger/ and its spec under packages/tasks/src/__tests__/, which is where every existing task spec in that package lives.

SymbolTask fileTriggerWhat it does
DECISION_OUTCOME_POSTBACK_DISPATCHERpackages/tasks/src/tasks/trigger/decision-outcome-postback.task.tsEnqueued when an Approval or Escalation carrying originConversationRef is settledPosts the outcome back into the originating conversation through the connector/channel facade within 60 s (FR-78). Retries 30 s → 2 m → 8 m, max 4 attempts; on final failure records the failure so the settled item can show "Couldn't reply in {channel}" (FR-79).
SHARED_VIEW_COUNTER_FLUSH_DISPATCHERpackages/tasks/src/tasks/trigger/shared-view-counter-flush.task.tsCron, every 5 minutesFlushes buffered view counts from the cache into shared_views.viewCount / lastViewedAt, so a public read never writes to Postgres on the request path (NFR "Latency"). Idempotent: the buffer key is cleared inside the same operation that applies the delta.

The first-view notification (FR-46) is raised inline by the flush task, not on the request path, via a new notifySharedViewFirstView() producer on packages/agent/src/notifications/notification.service.ts.

Deliberately not background work: the admission gate (must be synchronous, it decides whether to spend money) and the projection (must be live).


7. Plugin boundaries (Constitution I & II)

  • No new plugin package. This epic adds no external integration. It reads from the inbound path that already exists and replies through the facade that already exists.
  • No hardcoded plugin id anywhere outside a plugin. The allowlist keys on a Connection and a binding, not on a provider name. The gate reads IngestInstallBinding.provider as data. Reply delivery goes through packages/agent/src/facades/notification-channel.facade.ts (or, once available, the connector facade), which resolves the plugin by capability.
  • The gate implements a contract the plugin layer already declares. ChannelGuestAdmissionService.authorize matches ConnectorPairingAuthorizer and returns ConnectorAuthorizationDecision from packages/plugin/src/contracts/capabilities/connector.interface.ts, so the connector inbound runtime binds to it rather than growing a rival gate.
  • The service-name string shown in the add-guest form ("Their ID is on their profile in {service}") is resolved from the plugin's own manifest display name via the registry — never a switch statement over ids in apps/web.

8. i18n

New leaf keys in apps/web/messages/en.json. Leaf names are camelCase and contain no literal dot — next-intl rejects dotted leaf names at runtime and the hydration spec turns that into a multi-shard e2e failure.

8.1 dashboard.sharing (Settings → Sharing)

title, subtitle, navLabel,
offHeading, offBody, seeList, neverSeeList,
turnOn, turnOff, preview, regenerate, copy, copied,
liveBadge, linkLabel,
countersLine, countersNeverOpened,
sectionsHeading, sectionBoard, sectionBoardHelp,
sectionKnowledge, sectionKnowledgeHelp,
classPickerHelp, classPickerFooter,
indexingHeading, indexingBlocked, indexingAllowed,
notOwnerNotice, notOwnerSummary,
regenerateConfirmTitle, regenerateConfirmBody, regenerateConfirmCta,
indexingOnConfirmTitle, indexingOnConfirmBody, indexingOnConfirmCta,
indexingOffConfirmBody,
turnOffConfirmTitle, turnOffConfirmBody, turnOffConfirmCta,
loadError, retry

8.2 dashboard.channelGuests (the allowlist panel)

heading, helper, counter, ownerRow, ownerAlwaysAllowed,
addHeading, fieldExternalId, fieldDisplayName, fieldNote, addCta, addHelp,
statusActive, statusRevoked, requestCount, lastSeen, neverMessaged,
notReadyHeading, notReadyBody,
fullHelper,
revokeConfirmTitle, revokeConfirmBody, revokeConfirmCta,
duplicateError, limitError, loadError

8.3 share (a new top-level namespace — the public page has no dashboard chrome)

readOnlyBadge, tabBoard, tabKnowledge,
footerUpdated, footerPaused, resume, refreshNow,
emptyBoard, emptyKnowledge,
notActiveTitle, notActiveBody,
throttledTitle, throttledBody,
documentUnpublished, backToDocuments,
searchPlaceholder, searchResults, searchTooShort,
previewBannerOn, previewBannerOff, previewClose,
columnBacklog, columnInFlight, columnNeedsYou, columnDone,
needsDecision, staleFlag, moreCount,
agentsHeading, agentWorking, agentIdle, agentPaused, agentInFlight,
recentlyHeading, poweredBy

A new top-level namespace (rather than nesting under dashboard) keeps the public bundle from pulling in dashboard copy the visitor never sees.

8.4 notifications-v2 additions

sharedViewFirstViewTitle, sharedViewFirstViewBody,
channelGuestDeniedTitle, channelGuestDeniedBody

8.5 Agent-facing channel replies

The six strings in spec §6.10 are not UI copy — they are produced server-side and must be localised to the owner's locale. They live under api.channelGuest in the API's own message catalogue and are resolved through the same next-intl message store the mail templates use, with {ownerName}, {note} and {channel} interpolations.

8.6 Sibling locales

Add the same keys to the 20 sibling locale files in apps/web/messages/. Untranslated values fall back to English; a missing key does not, so the key set must be complete in every file.


9. Telemetry and failure modes

9.1 Telemetry

EventWhereProperties (never the token, never a message body)
shared_view.enabled / .disabled / .regeneratedOwner controllerorganizationId, sections, searchIndexable, rotationCount
shared_view.viewedCounter flush task, aggregatedorganizationId, views in window, section
shared_view.throttledPublic throttle guardbucket (token | client)
channel_guest.added / .revokedGuests controllerconnectionId, provider, guestCount
channel_guest.gateAdmission serviceoutcome (admitted | denied | throttled), provider
decision.postbackPost-back taskoutcome, attempt, succeeded

Redaction: the share token, the view session, the external user id and every message body are excluded at the emit site, not filtered downstream. The generic request recorders (request log, Sentry, PostHog, error context) additionally apply redactSecretUrl / redactSecretValue (§4.2) so a future route or a mistake cannot reintroduce the token.

9.2 Failure modes and the chosen behaviour

FailureBehaviourWhy
Token decrypt fails (key rotation gap)Owner read returns the settings with link: null and a "Couldn't read your link — regenerate it" line. The public path is unaffected (it matches on hash).The public contract must never depend on the encryption key being present.
Projection query times outPublic page serves the last successful render from the client's own memory plus "couldn't refresh"; a cold load returns 503 with the same chromeNever blank a page a visitor is watching.
Activity strip contains an unclassified action typeThe line is dropped and a warning is logged; CI has already failed on the classification specFail closed (FR-20).
Counter flush task failsCounts stay buffered and are applied on the next tick; the buffer has a 24 h TTL so a long outage loses counts rather than growing unboundedA view counter is not worth durable queueing.
Post-back task exhausts retriesThe settled decision records the failure; the owner sees "Couldn't reply in {channel}"FR-79.
Binding disappears (connection deleted) mid-conversationGuests cascade-delete with the binding; the gate denies; the post-back task short-circuitsOne ownership record, one cascade.
Two tabs regenerate simultaneouslyThe write is an atomic UPDATE … WHERE rotationCount = :seen; the loser gets 409 and re-readsS-10.
Guest revoked mid-runThe reply suppression check runs at post time, not at dispatch timeS-17.
A 429 on the public pathNever counted as a view, never logged per-request (aggregated only)FR-45 and log-volume sanity.
View session expires or its link is regenerated mid-pollThe next read returns the FR-11 response; the client re-exchanges once with the token in a body; if that also fails, the page shows no longer activeFR-7a, FR-8.
No session secret configuredPOST /sessions returns 503 and every read is refusedFail closed, as the terminal attach token does.

10. Test plan (Constitution VI)

10.1 Unit — packages/agent (Jest)

FileCovers
packages/agent/src/entities/__tests__/shared-view.entity.spec.tsColumn defaults; sections default shape; knowledgeClasses defaults to []
packages/agent/src/entities/__tests__/channel-guest.entity.spec.tsDefaults, status enum
packages/agent/src/shared-views/__tests__/shared-view-token.spec.ts256-bit generation, hash stability, encrypt/decrypt round trip, token never in toJSON()
packages/agent/src/shared-views/__tests__/publish-filter.spec.tsExact key-set assertions on every published DTO; a Task carrying cost/budget/comment fields and a missionId yields a card without any of them
packages/agent/src/shared-views/__tests__/publishable-activity.spec.tsEvery ActivityActionType member is on the publish allowlist or the never-publish list — fails CI on an unclassified addition
packages/agent/src/shared-views/__tests__/shared-view-projection.service.spec.tsColumn order and membership match the private Focus-layout fixture; cancelled Tasks, recurring templates and board-hidden Tasks absent and uncounted; +N more overflow arithmetic
packages/agent/src/shared-views/__tests__/knowledge-publish-predicate.spec.tsDraft / archived / proposed / deselected-class / excluded → not published; empty class list → zero documents
packages/agent/src/shared-views/__tests__/shared-view.service.spec.tsCreate idempotency; regenerate resets firstViewNotifiedAt; pause keeps the token; optimistic-concurrency 409
packages/agent/src/channel-guests/__tests__/channel-guest-admission.service.spec.tsGate order; owner always admitted; revoked denied; caps; the 24 h single-refusal ceiling; zero facade calls on deny
packages/agent/src/channel-guests/__tests__/requester-attribution.service.spec.tsLabel format; label stamped on task/approval/escalation, and on a mission only when the Run sets one up; owner work has no label; revoked suffix
packages/agent/src/channel-guests/__tests__/guest-text-fence.spec.tsForged boundary markers neutralised; control markers stripped; truncation at 4,000 chars

10.2 Controller specs — apps/api (Jest)

FileCovers
apps/api/src/shared-views/shared-views.controller.spec.tsOwner-only writes; non-owner member gets settings without the link; non-member 404; throttle decorators present
apps/api/src/shared-views/shared-view-public.controller.spec.tsUnknown / rotated / paused tokens return byte-identical bodies; every security header present; X-Robots-Tag omitted only when searchIndexable; 429 carries Retry-After; no route declares a path or query parameter named or shaped like a token (reflective check over the controller's route metadata)
apps/api/src/shared-views/shared-view-session.service.spec.tsMint/verify round trip; tampered MAC, expired exp, wrong rot and paused view all refused identically; no secret → mint 503, verify refuses; claims contain no token or token hash
apps/api/src/logging.interceptor.spec.ts (extend)A request to /share/<token> and a failing request carrying a token both log [redacted], never the token
packages/monitoring/src/redaction/__tests__/secret-url.spec.tsredactSecretUrl / redactSecretValue over share paths with and without locale prefix, query strings, Bearer sessions, JSON bodies; a non-secret path is unchanged
packages/monitoring/src/interceptors/__tests__/sentry.interceptor.spec.ts, posthog.interceptor.spec.ts, packages/monitoring/src/sentry/__tests__/sentry.config.spec.ts (extend)URL, tags, endpoint, transaction name and breadcrumbs are redacted
apps/api/src/shared-views/shared-view-owner.guard.spec.tsResolves the Tenant owner; throws NotFoundException, never ForbiddenException
apps/api/src/channel-guests/channel-guests.controller.spec.tsCRUD; bindingReady:false shape; duplicate 409; caps 422; non-owner 404
apps/api/src/ingest/slack/slack-chat-bridge.service.spec.ts (extend the existing spec)The gate is called after signature verification and before OpenAiCompatService; a denial short-circuits

10.3 End-to-end

FileCovers
apps/api/test/shared-view.e2e-spec.tsFull publish → exchange → read → regenerate → old-token-dead and old-view-session-dead cycle against a real HTTP stack
apps/api/test/shared-view-log-hygiene.e2e-spec.tsCaptures every line emitted by the Nest Logger, every Sentry capture/context/breadcrumb call and every PostHog trackEvent call while running an exchange, a board read, a knowledge read, an unknown-token exchange, a regenerated-away read, a throttled read and a forced 500; asserts neither the token nor the view session appears as a substring anywhere
apps/web/e2e/shared-view-token-transport.spec.tsRecords every network request the published page makes over three poll cycles; asserts no request URL contains the token, no PostHog request is issued, and the token appears only in POST /sessions bodies
apps/web/e2e/shared-view-publish.spec.tsOwner turns sharing on, copies the link, previews as a visitor
apps/web/e2e/shared-view-public-page.spec.tsVisit in a fresh context with no storage state; board renders; no cookie is set; no sign-in prompt; footer updates
apps/web/e2e/shared-view-revoke.spec.tsRegenerate in one context, assert the other context's open page shows "no longer active" within 20 s
apps/web/e2e/shared-view-noindex.spec.tsRobots headers/meta/crawler file present when blocked, absent when allowed
apps/web/e2e/shared-view-a11y.spec.tsAxe pass on the public page in light and dark; keyboard traversal; 360 px layout
apps/web/e2e/channel-guests.spec.tsAdd, rename, revoke; caps; the not-ready state; non-owner sees nothing

Every e2e that visits /share/:token must use a browser context with no storage state, or it proves nothing about anonymous access.


11. Phasing

Each phase is independently shippable and leaves develop green.

P1 — Publish (no new behaviour for anyone who does not turn it on)

  1. SharedView entity + migration + registry entries.
  2. Token service (generate / hash / encrypt / decrypt).
  3. Publish filters + closed DTOs + the activity classification spec.
  4. Owner controller + owner guard + Settings → Sharing page.
  5. Public controller (token exchange + view-session guard) + security-header interceptor + throttle buckets + the request-recorder redaction and its log-hygiene e2e.
  6. /share/[token] page (with the per-view robots meta), PUBLIC_ROUTES entry.
  7. Counter-flush task + first-view notification + event-type registration.
  8. i18n (dashboard.sharing, share) + the tests in §10.

Ships: FR-1…FR-24, FR-34…FR-49. Does not ship: the Knowledge section (its toggle renders disabled with "Coming soon" behind the existing soon copy pattern), guests, attribution.

P2 — Participate

  1. ChannelGuest entity + migration + registry entries.
  2. Attribution columns migration.
  3. ChannelGuestAdmissionService (the gate) + insertion into the Slack bridge.
  4. RequesterAttributionService + the fence helper + label stamping.
  5. Owner-only decision routing + the post-back task.
  6. Guests panel inside the channels settings page + requester label on the Task card, Task detail, Mission detail and My Decisions row.
  7. i18n (dashboard.channelGuests, api.channelGuest) + the tests in §10.

Ships: FR-50…FR-79.

P3 — Refine

  1. Knowledge section: class picker, publish predicate, public list/read/search endpoints, the two-pane reader.
  2. work_knowledge_documents.sharedViewExcluded + the per-document control.
  3. Guest activity report on the Sharing page (requests per guest, last 30 days).
  4. Resolve the §9 open questions that survive review — link expiry and pairing codes are the two most likely to land here.

Ships: FR-25…FR-33 and the deferred half of FR-28.


12. Constitution compliance

PrincipleJustification
I — Plugin-first architectureNo external integration is added. Outbound replies go through the existing notification-channel facade; inbound rides the existing signature-verified receiver.
II — Capability-driven resolutionThe allowlist keys on a Connection and a binding; the provider name is read as data. The add-guest form's service label comes from the plugin manifest via the registry, not a switch in apps/web.
III — Source-of-truth repositoriesThe Knowledge section publishes a projection of documents whose bodies stay in the user's own git repository. Nothing is copied into our database to publish it.
IV — Job runtime via *_DISPATCHERBoth background jobs (§6) are enqueued through DI symbols registered in _tasks-symbols.ts; no call site imports a job-runtime SDK directly.
V — Forward-only migrations, same PRFour migrations in apps/api/src/migrations/ (§3.5), each shipping with the entity change that needs it. Every down() drops only what its up() added.
VI — Tests are a prerequisite§10: 10 unit files, 5 controller specs, 7 end-to-end specs, including the key-set assertions that make an accidental field leak a CI failure.
VII — Secret hygieneThe token is stored with EncryptedJsonColumn, returned only to the Tenant owner, excluded at every telemetry emit site, and never written to an activity-log row. It never appears in an API URL — it is exchanged in a body for a short-lived, revocable view session (§4.2) — and every request recorder redacts it before writing. Visitor IPs are never persisted.
VIII — Single source of truth for plugin listsn/aNo plugin is added, removed or re-categorised.
IX — Behaviour-first specspec.md names no class, no path and no code; every implementation detail lives here.
X — Forward-looking backwards compatibilityEvery new column is nullable or defaulted; every endpoint is new; no existing DTO field is renamed or removed; ActivityActionType members are appended, never reordered.
Program rule #1 — additive onlyNothing is removed or renamed. The private dashboard, the member roster, the invitation flow and the inbound receiver behave exactly as before for anyone who does not opt in.
Program rule #2 — no duplicate nounsTwo new nouns, both justified in spec.md §5.3 and both to be added to the program vocabulary table in the same PR.
Program rule #9 — every surface answers "what did it cost?"A denied inbound message provably costs zero (the gate runs before any facade call, asserted in §10.1). Admitted guest work produces Runs whose receipts are the existing ones — this epic adds a requester label to them, not a parallel accounting path.