feat(announcements): data layer, admin CRUD, and table (PR-1) - #966
Merged
Merged
Conversation
…nouncements PR-1 of docs/specs/feature-announcements.md. Ships dark: admins can author drafts, users see nothing until PR-2 adds the user-facing surface. Storage (apis/shared/announcements/): announcement items on the fixed `ANNOUNCEMENTS` partition, acknowledgement items under `USER#<id>` with revision-keyed sort keys (`ACK#<id>#R<n>`). The full field set is modelled now, including the fields later PRs consume — the table is the expensive thing to change, the routes are not. The ack write is a conditional UpdateExpression guarded by `attribute_not_exists(actionRank) OR actionRank < :rank`, and a ConditionalCheckFailedException is swallowed as success. `seen` is written on render and races the user's dismiss click; without the guard the late `seen` clobbers `dismissed` and the banner comes back. Same failure class as #741 / #751 — per-user state moving backwards — so the guard lives in the database, not in application ordering. `targetRoles` is a display filter, not an RBAC grant (spec D9). It is written only to the announcement item; apis/shared/rbac/ is untouched beyond the new scope. Commented on the field so it does not get "fixed" into the role service. Also: `state` is absent from the PATCH body, so the publish/archive state machine cannot be walked around by a request that looks like a body edit, and create accepts only `draft` / `scheduled` — going live is its own call. - admin CRUD at /admin/announcements, guarded package-wide by the new delegable `admin.announcements` scope (backend registry + the duplicated SPA literal union) - `ANNOUNCEMENTS_ENABLED`, default ON with a kill switch; the admin router unmounts when explicitly false - CDK: AnnouncementsTable in AdminTablesConstruct with `timeToLiveAttribute`, SSM param, PlatformComputeRefs threading, app-api env var + IAM grant, DDB alarm coverage, and the backup/restore table lists. No GSI. Tests: 70 backend cases covering the monotonic-ack regression, revision keying, expiresAt validation for loud surfaces, `javascript:` rejection at the API layer, and the flag-off 404. Infra table-count assertions and the GSI inventory updated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR-1 of
docs/specs/feature-announcements.md— the storage layer, the admin authoring API, the delegated admin scope, and the DynamoDB table.This ships dark
There is no user-facing surface in this PR. Admins can author drafts; users see nothing.
GET /announcements, the ack endpoint, and the What's-New panel land in PR-2.This PR adds a CDK table, so
platform.ymlmust deploy beforebackend.ymlor app-api will have noDYNAMODB_ANNOUNCEMENTS_TABLE_NAMEand no IAM grant. That failure mode is benign — the repository logs a warning and disables itself rather than crashing (the same postureAuditServicetakes for the same reason), so a backend-first deploy degrades to "the admin API returns empty" rather than taking anything down. But the admin surface is not usable until the platform deploy lands.No GSI, so none of the GSI deploy-ordering hazards apply.
What's here
Storage —
backend/src/apis/shared/announcements/{models,repository,service}.pyANNOUNCEMENTSpartition,SK: ANNOUNCEMENT#<uuid>— the same shapeuser_menu_linksuses.PK: USER#<user_id>,SK: ACK#<announcementId>#R<revision>— the per-user partitionuser_settingsestablished, revision-keyed per spec D4.revision/showToNewUsers/requiresAck/targetRolesthat later PRs consume. The table is the expensive thing to change; the routes are not.Admin API —
POST/GET/PATCH/DELETE /admin/announcementsplus/publish,/archive,/revise. Guarded package-wide by the new delegableadmin.announcementsscope, in both the backend registry and the duplicated SPA literal union. (/statsis deliberately PR-6.)Feature flag —
ANNOUNCEMENTS_ENABLED, default ON with a kill switch (!= "false"), matchingskills_enabled/scheduled_runs_enabled. Explicitly false unmounts the admin router so the surface 404s.CDK —
AnnouncementsTablebesideUserMenuLinksTablewithtimeToLiveAttribute: 'ttl', SSM param,PlatformComputeRefsthreading,DYNAMODB_ANNOUNCEMENTS_TABLE_NAME, an IAM grant, DDB throttle-alarm coverage, and the backup/restore table lists.Three things worth reading closely
The ack write is monotonic at the database (spec D2).
record_ackis a conditionalUpdateExpressionguarded byattribute_not_exists(actionRank) OR actionRank < :rank, and aConditionalCheckFailedExceptionis swallowed as success.seenis written the moment a surface renders, so it races the user's click on the ✕; without the guard the lateseenoverwritesdismissedand the banner comes back on the next load. This is the same failure class as #741 and #751 — per-user state moving backwards — so it gets the same discipline: the guard is in the condition expression, not in application ordering. The write path is built here even though PR-2 is what calls it, andTestMonotonicAckis the first class in the test file.targetRolesis a display filter, not an RBAC grant (spec D9). CLAUDE.md's rule that a role list on a resource must be written through to eachAppRole.granted*governs tools, models, and skills — things with acan_access_*predicate behind them. Announcement visibility confers no capability and inherits nothing, so the list lives only on the announcement item andapis/shared/rbac/is untouched beyond adding the scope. There's a comment on the field saying so, and a test that pins it, because "fixing" this into the role service would put display metadata into the access-decision path.One thing not in the spec, added here.
stateis absent from theAnnouncementUpdatebody. If PATCH accepted it, an archived announcement could be put back in front of every user by a request that looks like an ordinary body edit, and the/publishguard would be decorative. For the same reason create accepts onlydraft/scheduled— going live is its own call. Both are covered by tests. Flagging it as a deviation in case you'd rather PATCH stay fully general.Also deferred deliberately
AuditActionis a closed constant namespace that today names only role actions, and extending it touches the non-delegableadmin.auditarea. Better as its own change than smuggled into this one — worth doing before the scope is actually delegated to comms staff.config.announcements.enabledCDK threading (spec §8.4). Not wired, matchingMID_TURN_STEERING_ENABLED, which also relies on default-ON with no env var. Nothing is needed for the flag to resolve enabled.Testing
7399 passed, 3 skipped. 70 of those are new, covering:
seenafterdismissedleavesdismissedintact), plus idempotency, user scoping, and that a stronger action still raises the rank;seen→acknowledgedon arequiresAckannouncement removes the TTL rather than letting the compliance record expire on the earlier schedule;expiresAtrequired for banner/modal, enforced on create and on a PATCH whose merged result is invalid;ctaUrlrejectingjavascript:at the API layer, on both POST and PATCH;tests/architecture/test_admin_scope_coverage.pypicks up the new admin package — verified by running it, not assumed; itsEXPECTED_MODULE_SCOPESmap needed the new entry, which is the mechanism working as designed.784 infra tests pass, tsc clean, synth clean. Four table-count assertions needed updating (26 → 27 tables,
AdminTablesConstruct3 → 4, the DDB alarm count) andgsi-inventory.jsonwas regenerated — it now recordsannouncements: [], confirming no index was added. Added a construct test asserting the TTL attribute is on the announcements table only, since a TTL that spread to a sibling would silently delete admin-authored content.205 files / 2234 tests pass — the SPA change is the one-line scope-union addition.
🤖 Generated with Claude Code