The container/workspace reachability probes (socket connect + HTTP check)
ran synchronously with real timeouts inside async request handlers and the
live-view relay's 3-4s ticks, freezing the whole event loop whenever a
container wasn't cleanly reachable - the likely cause of the periodic
app-wide stalls. Converted the probe chain (api._port_reachable/_http_probe,
editor_reachable, instance_runtime, provision.editor_ready/view) to real
async I/O and parallelized the admin container/workspace list decorators.
Also fixes: XmlrpcService.on_disable blocked up to 10s on a synchronous
subprocess.wait inside an async method (now matches TelegramService's
async-subprocess pattern); JobService._sweep_expired ran every job kind's
cleanup() - including shutil.rmtree on large directories - synchronously
on every tick, now offloaded via asyncio.to_thread for all job kinds at
once; and several smaller blocking reads/writes on request/service paths
(attachment-to-gitea mirroring, stealth chunked downloads, dbapi/isslop
file reads, job payload/report I/O) moved off the loop thread.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tunnels.create() checked for an existing row by hostname and then
inserted/updated in a separate statement, with no lock between the two.
Concurrent publish calls for the same port (e.g. the editor's Ports
extension re-POSTing on every onDidChangeTunnels event) could race past
the check together and each insert their own row for the identical
hostname. Since routers/tunnel.py resolves a tunnel by a plain
find_one(hostname=...) with no ordering, whichever duplicate it happened
to return decided whether a request reached the app or got a generic
"no tunnel is published at this address" 404 - even while a sibling row
for the same hostname was active and serving traffic.
Replaced the check-then-write with a single atomic
INSERT ... ON CONFLICT(hostname) DO UPDATE, which needs hostname to
actually be unique: idx_tunnels_hostname is now a UNIQUE index instead
of a plain one. Since existing databases likely already carry duplicate
rows from this race, init_db() now runs a one-time
_dedupe_tunnel_hostnames() pass (keeps the best row per hostname -
active > provisioning > pending > failed, then newest) before dropping
and recreating the index as unique - CREATE UNIQUE INDEX IF NOT EXISTS
silently no-ops when an index with that name already exists, so the old
non-unique index has to be dropped first or the constraint would never
actually upgrade.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
make dev/prod hardcoded uvicorn to port 10500 with no way to override it,
so config.PORT (and everything derived from it - INTERNAL_BASE_URL,
Devii's own base URL defaults) stayed 10500 regardless of what port the
process actually bound to. DEVPLACE_PORT now drives all of it, defaulting
to 10500. Docker's existing PORT var (the externally published port via
nginx) is unrelated and untouched; its app container pins DEVPLACE_PORT
to 10500 explicitly so a bare-metal .env value can never leak in and
desync it from the Dockerfile's fixed internal bind port.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The old sync compared only the two live sides (project rows vs workspace
files), so a file present in the project but missing on disk was
indistinguishable from "never materialized here yet" - it always got
re-exported, which is why deleting a file inside a container made it come
back. The mirror direction had the same bug: a file deleted from the
project's file editor was silently re-imported from the container's stale
copy on the next tick.
Fixes it with a persisted per-file sync baseline (new project_file_sync_state
table: db_epoch/fs_epoch as they stood right after the previous sync), the
same role a rsync/Unison state file plays in any real bidirectional sync.
Deleting on either side now propagates to the other, unless the deleted
side's counterpart was edited after the last sync, in which case the edit
wins and the file is restored. A read-only project always exports (never
imports, including on tie) and always removes a workspace's stale local
copy, so it stays a faithful mirror. Sync of an unchanged file is now a true
no-op (zero writes) instead of rewriting it every ~60s tick forever.
sync_dir_bidirectional's return dict gains deleted_in_project/
deleted_in_workspace alongside exported/imported; both API call sites
already pass the whole dict through untouched. The instance sync toast now
summarizes all four counts instead of just imports.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TjdKTWgWpW2SMNW8SFqxz5
Admin-unlimited Dev Workspaces: an admin-owned workspace is now exempt from the
max-workspace-count limit, the max-tunnel-count limit, and the whole
idle-stop/idle-warn/retention-delete lifecycle. Resolved once in
quota.resolve() as Limits.unlimited (owner uid checked against
get_admin_uids()), consumed at the three enforcement points
(provision.ensure, provision.publish_tunnel,
WorkspaceService._advance_lifecycle). Also hardens
get_admin_uids()/get_primary_admin_uid() against a partially-schemaed users
table (uid/role column guard), which a fresh test/init_db() path could hit.
AI gateway per-model automatic fallback: any gateway_models route
(chat/embed/image) can now name a fallback_model, picked on /admin/gateway
from a select box of other configured public model names of the same kind
only (never an internal upstream model id). When a route fails after its own
retries are exhausted, the gateway retries once, automatically, against the
fallback's own provider/pricing/key, before any bytes reach the client
(including for a streaming response). One hop only, no chains or cycles;
self-reference and cross-kind fallbacks are rejected at write time.
AI gateway real upstream streaming and thinking-default control: stream:true
is now forwarded to the upstream and relayed to the client as real SSE
chunks (measured TTFT/inter-token latency) instead of a simulated split
response, and every chat/vision call explicitly disables model "thinking" by
default (admin-overridable via gateway_thinking), with per-dialect handling
for DeepSeek, OpenRouter, and Ollama.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TjdKTWgWpW2SMNW8SFqxz5
Every AI-calling feature (content correction, the @ai modifier, quiz grading,
SEO metadata generation, the AI Usage Analyzer, and DeepSearch) can now name
its own model via admin-editable configuration, defaulting to the gateway's
default model when left blank. The gateway a feature talks to stays fixed to
the internal endpoint either way, only the model name is a knob, so the best
model can be picked per task.
Also splits the news service's shared AI grading/formatting config into two
independent endpoint/model/key pairs: reformatting no longer requires
repointing (and thereby breaking) the free, scoring-only grading model.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZ5x6KZTxZjbJqsEkxGbxG
- Route Devii-driven AI gateway cost to the action/tool that triggered
it instead of a blanket "internal" bucket, so per-feature AI spend
is attributable.
- Fix the quiz attempt review to show one previously-answered question
at a time instead of all of them at once, and stop a quiz endpoint
linked from the quiz flow from responding with raw JSON.
- Add DB API async query result route and AI Usage Analyzer annotated
source/media routes, with traversal-safe uid/path handling and
matching tests.
- Add Code Farm action audit logging (plant/harvest/buy-plot/upgrade/
fertilize) and related admin workspace/services/trash/gateway route
and doc touch-ups.
- Drop redundant docstrings from access_tokens.py per the no-comments
convention.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK
- Fix StatisticsOut to match the real admin_statistics page context
(active_tab/tabs/window_hours/initial), which was raising a
ValidationError and 500ing GET /admin/statistics with
Accept: application/json.
- Add missing *Out fields dropped from JSON responses: IsslopSourceOut
(source_lines, marked_lines, focus_line, report_url), IsslopReportOut
(report_url, badge_url, events_url, topic), IssuesOut
(viewer_is_admin), GameStateOut/GameFarmViewOut (game_error),
QuizAttemptPageOut (answer_max_chars, quiz_error), QuizBuilderOut
(quiz_error).
- Convert GET /admin/issues/planning to respond() with a new
AdminIssuesPlanningOut schema so it serves JSON like every sibling
admin dashboard, and document it in docs_api.
- Document previously-undocumented admin endpoint families in
docs_api: services page routes, gateway provider/model CRUD, admin
workspaces (11 routes), and trash list/restore/purge.
- Correct stale references to services/devii/actions/catalog.py as a
single file; it is the actions/catalog/ package.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK
Notifications: a new "thread" type notifies every other commenter on a
post whenever anyone comments on it, disregarding reply hierarchy -
excluding the actor and whoever already got a comment/reply
notification for that same event, so no one is double-notified.
Implemented via a background-deferred fan-out mirroring the existing
mention-notification pattern.
SEO: discussion_forum_posting() now embeds up to 20 of a post's
comments as nested schema.org Comment entities (not just an aggregate
count), and a new /topics hub plus /topics/{topic} pages give the
feed's topic filter real, independently crawlable/indexable URLs -
/feed?topic=X was never indexable since its canonical strips the
query string back to bare /feed. Both are wired end to end (schemas,
Devii actions, docs API, sitemap, locustfile load-test coverage).
Quiz player: the auto-advance to the next question used to hide the
just-answered slide in the same tick as rendering the grade, so on
any multi-question quiz the Correct/Not correct feedback was never
actually visible before the view moved on. Delayed via setTimeout,
with the pending timer cleared on manual navigation and on
disconnect so it can't race or fire on a removed component.
Also includes other local changes already in progress in this
working tree before this session (messaging, push delivery,
deepsearch jobs, game economy, quiz builder) - verified by the full
suite passing (3467 tests) but not authored or individually reviewed
in this session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK
Composer focus/blur no longer fights the on-screen keyboard: tapping
Send blurred the textarea first, which unconditionally zeroed the
visualViewport keyboard-inset compensation before the immediate
refocus could recompute it, so the composer could sit misplaced for
up to 600ms after every mobile send. Blur now only resets the inset
when focus is actually leaving the composer form, and focus
recomputes it immediately instead of waiting on the retry timers.
Emoji picker and @mention wiring, previously only ever applied once
at page load, are now re-run whenever AppChat rebuilds a composer for
a client-side conversation switch, so both survive tapping a
conversation from the list instead of only working after a full page
reload. Viewport meta gains interactive-widget=resizes-content for
native keyboard-aware layout. Chat scroll panes get
overscroll-behavior: contain plus momentum scrolling, matching the
pattern already used elsewhere in the app. The conversation-search
dropdown's literal z-index is replaced with the --z-popover token.
Adds sticky Today/Yesterday/date separators between message groups
(chat/DateSeparators.js, viewer-local-time day boundaries, normalized
on every thread mutation) and a debounced "Reconnecting..." banner
reflecting live WebSocket state.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qsdit8UXhbUn9ZnfgjbXqt
The auth matrix probes documented endpoints with their documented form
params; the faction param was declared with location body instead of
form, so the anonymous probe sent an empty body and hit 422
validation before the auth guard. And the first fight always triggers
a lead-change event that outranks the fight event at the top of the
ticker, so the e2e flow now asserts on the ticker as a whole.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A new post attachment type beside polls: the composer gains a Start
Opinion War builder (same disabled-inputs opt-in as the poll builder)
that names exactly two factions; the battle runs for exactly 7 days
from post creation. Members join a side, may defect at any time
(damage already dealt stays with the faction it was dealt to), and
fight once per 24 hours per battle. A fight spends 25 Code Farm coins
and deals deterministic level-weighted damage: 100 + 10 * min(level,
20) HP, so a newcomer deals 110 and a veteran caps at 300 - no
randomness anywhere.
The battle renders on the post card as a CSS pixel-art battlefield
(box-shadow sprites: castles, faction flags, marching soldiers, a
flickering campfire; steps() animation, disabled under reduced motion)
with live HP bars, a countdown, the viewer's faction strip, top
contributors and an event ticker. Live frames ride pub/sub on
public.battle.{uid} via a relay on the service-lock owner, with the
durable opinion_war_events trail (per-war atomic seq) as the source of
truth and a 15s incremental poller as fallback. /battles lists battles
with active/ended/mine filters, search and pagination.
Every mutation is a conditional UPDATE via conditional_update_row: the
fight sequence claims the cooldown first, then spends coins, then lands
the damage, compensating earlier steps on any later refusal so a crash
costs a turn, never coins. Resolution is lazy on read (no cron):
an exactly-once CAS computes the winner in the statement, awards XP
(participation, winner bonus, top damage dealer bonus; draws pay
participation only), emits the result event and notifies fighters. The
OpinionWarService backstop resolves unviewed wars and sends
fight-ready notifications, exactly-once via a marker CAS.
Fan-out: battle notification type, four badges, audit keys
(battle.create/join/switch/fight/resolve), Devii actions (join/fight
confirm-gated), API docs group, docs prose page, sitemap and topnav
entries, REPORTABLE_TARGETS registration, post-delete cascades,
README and nested CLAUDE.md documentation.
Verified with the four-layer procedure: property checks over the full
damage domain, 1200-step stateful fuzz (hp-sum invariant, coins never
negative, resolved totals frozen), and real 8-process races proving
exactly-once semantics for concurrent fights, double-spends across two
wars, resolution XP and double-joins. Persisted tests in
tests/unit/services/opinionwar, tests/api/battles, tests/e2e/battles
and tests/api/posts/create.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_attachment_display.html iterates a context variable named
`attachments`, so every caller binds it before the include.
_post_card.html was the one caller that did not: it guarded on
item.attachments but included the partial with nothing bound, so the
gallery looped over whatever `attachments` happened to be in the
surrounding page context and rendered empty. Every post with an image
looked image-less on the feed and on profiles, and on a project page -
where project_detail.html sets `attachments` at template scope for the
project's own files - a devlog card would have rendered the project's
files as its own.
With the image actually reaching the card, render a lone one properly:
a gallery holding exactly one item gets a `single` class and takes the
full content column (max-height 480px, object-fit contain, no hover
scale), matching the original DevPlace. That branch serves the stored
original rather than thumbnail_url, because a thumbnail is 200px on its
longest side and stretching it to the column width is visibly blurry.
Animated GIFs needed no change and now have a test proving it: they
never had a thumbnail to flatten, so they already took the original-file
path and simply render larger.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing Ctrl+V with a screenshot on the clipboard now attaches it
immediately instead of requiring a trip through the file picker.
The clipboard reader lives in dp-upload behind a new opt-in `paste`
boolean attribute: with it set, the component binds one paste listener
on its closest form and routes the clipboard image files through the
same handleFiles path as the picker and the drop target, so validation,
limits, the terms gate and the hidden attachment_uids field are shared.
A paste carrying plain text is never swallowed.
It is opt-in rather than a form-wide default because a form may hold
several upload buttons - projects.html has cover and logo beside the
attachment one - and a default would attach one pasted screenshot to
all of them.
Set on _attachment_form.html, so every form including it inherits the
behaviour (post composer, post edit, gists, projects, issues,
screenshots), plus _comment_form.html, messages.html and the embed-mode
skeleton AppChat builds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Outcome: done
Changed: tests/e2e/iosapp.py:1-94 (new file)
Verified by: python -m pytest tests/e2e/iosapp.py -q -> 5 passed (3 consecutive clean runs, incl. verify()); full make test -> 3348 passed, 1 skipped, 11 failed (9 tests/api/projects/workspace.py FileNotFoundError 'docker' = missing docker binary in container, pre-existing per sibling run; 1 tests/e2e/game/farm.py steal flake, passes standalone; 1 was this file's pre-fix settings-propagation race, fixed)
Findings: tests/e2e/iosapp.py locks the badge behaviour: footer badge visible on landing (.landing-footer) and /feed (.site-footer), guest topnav badge next to Login/Sign Up, mobile-panel badge after opening the hamburger, every href equals the configured ios_app_url with target=_blank rel=noopener noreferrer, and all badges disappear when ios_app_url is cleared (restored in try/finally).
Settings-flip e2e tests must sleep CACHE_VERSION_PROPAGATION_SECONDS (1.5s) after set_setting before navigating: the server caches the cache_state version for 1s (core.py:56), and the first iosapp run hit that race (badge absent on a stale render).
On a fresh DB the operational_defaults block incl. ios_app_url is never seeded because it is gated on the pre-init db.tables snapshot (schema.py:34 vs :1705), so the badge renders only after an admin sets ios_app_url.
Open: re-run make test on a docker-capable host to clear the 9 workspace.py environment failures; decide the fresh-DB ios_app_url seeding gap (PM/implementation node); farm s
Typosaurus-Run: 2910ed1ba2604be09c649141af1baf44
Typosaurus-Node: e8e69f65a5fa40c2bf6a8b0206cfb2d4
Typosaurus-Agent: @sveta
Refs: #169
Outcome: done
Changed: devplacepy/templates/base.html:126-132,189-195; devplacepy/templates/_footer_links.html:12-19; devplacepy/templates/admin_settings.html:33-38; devplacepy/routers/admin/settings.py:19; devplacepy/templating.py:197-199,218; devplacepy/static/css/base.css:720-731,785-793,1151-1161,1210-1212; devplacepy/database/CLAUDE.md:224,228
Verified by: import gate clean; py_compile clean; ruff/pyflakes no new findings; TestClient smoke (badges present with href=ios_app_url, target=_blank rel=noopener noreferrer, static_url-versioned src on /, /feed, /leaderboard; absent when setting empty; admin clear via /admin/settings POST verified); make test-fast: 2659 passed, 1 skipped, 10 failed - all environmental (9 missing docker binary, 1 missing libcairo.so.2), none caused by this change; Playwright e2e inapplicable (chromium cannot launch: libglib-2.0.so.0 missing)
Findings: Badges render only when ios_app_url site setting is non-empty and are editable/clearable via AdminSettingsForm (CLEARABLE_SETTINGS). Researched URL https://apps.apple.com/app/devplace/id6797215143 (trackId 6797215143, bundleId wok.DevPlaceIOS) confirmed via iTunes Lookup API and seeded in schema.py operational_defaults by sibling commit. Topnav badge hides at <=1024px where the mobile panel badge takes over, keeping the 360px layout overflow-free. Badge SVG is Apple's official white variant vendored at devplacepy/static/app-store-badge.svg with retoor header. Apple badge marketing guidance (40px min
Typosaurus-Run: 2910ed1ba2604be09c649141af1baf44
Typosaurus-Node: 2817fd803023406699936e88b76ee632
Typosaurus-Agent: @nadia
Refs: #169
Outcome: done
Changed: none
Verified by: not verified - review-only node; repo cross-checks done (devplacepy/templates/docs/contact.html:5, devplacepy/templates/admin_settings.html:192, devplacepy/database/schema.py:1746, devplacepy/static/app-store-badge.svg)
Findings: Verdict: accept with findings - every parent acceptance criterion is addressed by sourced, tiered findings; none of the notes below is blocking.
The verified URL is already consumed in-flight: uncommitted ios_app_url default at devplacepy/database/schema.py:1746 plus AdminSettingsForm field (devplacepy/models.py) and ios_app_url() Jinja global (devplacepy/templating.py); later nodes must not re-introduce it and must review that diff separately.
Apple's official badge SVG is vendored at devplacepy/static/app-store-badge.svg (title Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917, 119.66407x40), matching leaf-2's source recommendation; it sits at the static root rather than the leaf's suggested static/vendors/, a consistency-review call, and the leaf's own commit declared "Changed: none" despite adding it.
"Confirmed live" evidence is DuckDuckGo-indexed storefront snapshots (/fr/, /th/, /il/) via the sanctioned aggregator rsearch.app.molodetz.nl, direct egress to apps.apple.com being blocked and disclosed; corroborated by github.com/WilhelmOks/DevPlaceIOS, so a manual click-through before release is advised.
Apple's badge terms (min 40px onscreen height, one badge per layout) vs the ticket's "small badg
Typosaurus-Run: 2910ed1ba2604be09c649141af1baf44
Typosaurus-Node: 0c6857c89c9448589c0d0141ee774948
Typosaurus-Agent: @zoya
Refs: #169
DeviiTerminal bound [data-devii-open] click listeners only after the
async /devii/session fetch resolved, silently dropping early clicks.
Switch to a single delegated document listener bound in the
constructor, matching the ModalManager/dp-lightbox pattern.
The steal-confirm and comment-vote e2e tests asserted DOM state right
after a click with no wait for the triggering POST to land, racing the
server under CI load. Wrap those clicks in page.expect_response.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A shared-server crash mid-suite previously cascaded into hundreds of
opaque connection-refused errors across every later api/e2e test,
making the real cause invisible. pytest_runtest_setup now polls the
tracked subprocess and, on the first test after it exits, reports the
exit code plus the server's own log tail once instead of forcing every
subsequent test to fail blind.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Test-process reflections of the projects table cached a reduced schema
when they ran before the app server had ALTER-TABLE'd in website_url,
repo_url, cover_attachment_uid, logo_attachment_uid, platforms,
release_date, and demo_date, causing spurious KeyErrors under full-suite
ordering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PR #164 shifted --bg-primary/secondary/card/input/modal and the body
gradient to a purple-toned palette; restore the prior values.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The workspace editor hung for 60s and then 504'd. Three independent faults were
stacked behind that one symptom.
Reachability: editor_target delegated to proxy_target, which returns
CONTAINER_PROXY_HOST plus the published host port and never falls back to the
container. From inside the app container that address crosses docker0 into the
host INPUT chain, whose policy is DROP with an allow-list that does not include
the published port range, so the packet was dropped and the request hung rather
than being refused. Measured from the app container: container_ip:8443 answers
302, gateway:20006 is dropped. One shared reachable_target now prefers the
direct container leg and falls back to the published port, and editor_target
uses tunnel_target as services/containers/CLAUDE.md already required. The same
defect affected /p/{slug} ingress and every tunnel, since all three resolved
through proxy_target.
The recorded measurement that motivated the old order (container_ip times out,
gateway connects) no longer holds: make docker-attach puts the app on the
instances' bridge network, which is what makes the direct leg work.
Duplicate response headers: the forwarding core relayed the upstream Date and
Server alongside the ones the serving layer generates, so every proxied
response carried two of each. Both are singleton headers and duplicating them
is malformed HTTP.
Serialization: WorkspaceViewOut declared flag_reason and three sibling strings
as str, so a NULL column made the workspace page 500 for JSON clients.
Documents the two public hostnames and the devplace.net SSH tunnel, so a future
session does not conclude the site is down after pointing curl --resolve at an
address the hostname does not resolve to, and adds the layered procedure for
diagnosing a production failure.
Verified on production with Playwright over both hostnames: the code-server
login renders and the workbench loads. Suite: 3345 passed.
The cover and logo widgets declared allowed-types as bare extensions
(png,jpg,jpeg,gif,webp), but dp-upload builds the candidate extension
with a leading dot before testing membership, so every selected file
was refused with "type is not allowed". The four widgets were the only
hardcoded lists in the codebase: every other call site passes
allowed_file_types(), which defaults to empty and therefore disables
the client filter entirely, which is why nothing else exposed the
mismatch.
Rather than dotting a duplicated literal in four places, the effective
list now comes from a new allowed_image_types() Jinja global that
intersects allowed_extensions() with IMAGE_EXTENSIONS. That reuses the
one server-side choke point, so the widget can never advertise a type
the upload gate would reject, and narrowing the admin allowed_file_types
setting narrows these widgets with it. IMAGE_EXTENSIONS rather than
POST_IMAGE_EXTENSIONS is the correct set here because the route guard
is _hero_attachment_uid, which accepts any is_image attachment, and
bmp/tiff both upload and pass it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The project detail page becomes a full project showcase built entirely
from existing platform mechanisms. One encompassing dark card wraps the
page; inner panels (tab bar, sidebar cards, devlog entries, comments)
sit one elevation lighter. The hero opens with a cover banner and an
optional logo tile, both plain attachment references
(cover_attachment_uid/logo_attachment_uid) uploaded through the
standard dp-upload attachment widget and linked via the existing
link_attachments choke point - the route validates each uid belongs to
the actor and is an image, and an empty value on edit keeps the current
one. The title block, type/platform chips and author row overlay the
banner behind a scrim with a dark text shadow, next to an owner-set
Visit Website CTA; website_url and repo_url are normalized in models
and render with rel noopener nofollow.
An anchor tab bar (Overview, Devlog, Screenshots when present,
Comments, Files) navigates the page. The main column keeps About, the
devlog timeline (with devlog_count and an owner Post update button
opening the shared composer preset to the devlog topic + project - the
form now lives once in _post_composer_form.html, included by feed.html
and project_detail.html), a Screenshots gallery built from image
attachments minus the cover/logo (thumbnails, lightbox, 12 rendered),
and the comment thread; the sidebar holds Links, Stats and the Author
card. Owners add gallery images from the More menu via
POST /projects/{slug}/screenshots (owner-only, audit
project.screenshots.add, Devii action project_add_screenshots, docs id
projects-screenshots). comment_count/devlog_count ride
ProjectDetailOut, the new fields ride ProjectOut, and the create/edit
faces (modals, Devii actions, API docs) carry them. The project
comment/files e2e tests scope their locators per the documented
dual-control idiom, and new unit/api/e2e tests cover URL normalization,
the counts, the hero attachment guard, the screenshots flow and the
preset composer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
data/devplace.db is the live database and make dev, make prod and the
Docker stack all share it, so an agent-initiated command that reaches it
is a production incident waiting for a typo. The hazard is invisible in
the command text: the script that prompted this named no path at all, it
imported devplacepy and therefore resolved config.DATA_DIR to the real
file. A path-pattern rule would have sailed straight past it.
The PreToolUse hook reads the script and judges it on content, so one
that points DEVPLACE_DATABASE_URL at a scratch file passes and an
unguarded one does not. It also refuses commands naming the database or a
production data directory, the management CLI, and python -m devplacepy.
The suite, the server targets and the mandated import gate stay free.
permissions.deny additionally refuses Write and Edit anywhere under data,
which the Bash hook cannot see.
The escape hatch is two-factor and cannot be self-served: without
confirmation the command is denied outright rather than prompted, and the
token that downgrades it to a prompt may only be added after the user has
confirmed in their own words. Verified against thirty-five commands, and
the heuristic is narrower than it looks because the repository path
itself contains the package name, so it matches an import statement
rather than the bare word.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An instance kept production-identical for extended manual testing is
otherwise taxed forever by its own safety controls: five consents, a
versioned terms gate on every mutating request, and every account
predating the trust and safety commit reading terms_version NULL because
init_db deliberately never backfills it. AcceptanceService grants each
agreement to each account that has not declined it, so the instance stays
production byte for byte while nobody clicks the same dialog again. It is
opt-in, dry run by default, and one switch per agreement type.
The application is not allowed to know it exists. One registration line
in main.py is the only import anywhere, there is no route, schema,
template, Devii tool or environment flag, and a unit test greps the tree
and fails the suite if a second importer appears. The decline register
needs no storage: the ledger is append-only in effect, the service only
ever grants, so any withdrawn row was written by a human and that pair is
never touched again. No provenance column, nothing to observe.
Satisfaction is the gate's own expression, never a proxy, which is why
the ordering is created_at then id exactly as consent_state selects, and
why the live-account clauses are built with has_column: init_db ensures
terms_version and deletion_requested_at but not is_active, so a hardcoded
reference raises no such column on an instance where nobody was ever
suspended. Every write is one conditional statement decided on the real
rowcount, proven with sixteen processes racing one account to exactly one
ledger row and one audit row. The two existing audit keys carry it, with
actor kind service, because a service that silently mutated consent state
would be the worst possible exception to the append-only rule.
lensfl.md is the source brief accept.md records the design against.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>