Fix issues #002-#006 found by the LLM test harness

- #006 (high, data loss): make_room_for() no longer deletes unkept
  artifacts on a doomed request - checks whether eviction can possibly
  succeed before deleting anything. Also fixed a related latent bug found
  while testing: the function returned evicted Artifact objects after
  calling .delete() on them, so their .id was always None (Django clears
  pk on delete); now returns ids captured beforehand.
- #003: added server-side field-length validation (source/title/
  source_name/source_id/theme) on POST and PATCH, matching the model's
  own max_length limits, which were previously unenforced on the ingest
  path entirely.
- #004: ported daybook_post.py's content-shape validation
  (html_fragment/html_full/deck/markdown) to the server, closing the gap
  for any client that doesn't go through the helper script.
- #005: PATCH now rejects wrong-typed `tags` (previously silently wiped
  existing tags) and rejects immutable fields (content/source/kind/day)
  with 400 instead of silently no-op'ing them.
- #002: deleted the misleading skills/daybook-posting/.env.example (a
  zero-dependency script that never reads .env files) and clarified
  SKILL.md/docs/llm_posting_helper.md that these must be real
  process/shell env vars.

Adds tests/unit/ for storage-layer tests. 46 tests passing (was 25).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-02 13:38:59 +01:00
parent c1ea5c7990
commit 07328a3412
13 changed files with 635 additions and 35 deletions

View file

@ -0,0 +1,54 @@
# 002 — `.env.example` implies persistent config that `daybook_post.py` never loads
**Status:** Fixed
## Description
`skills/daybook-posting/.env.example` ships alongside the posting helper:
```
DAYBOOK_URL=http://127.0.0.1:8001
DAYBOOK_TOKEN=your-personal-api-token
DAYBOOK_SOURCE=agent
```
The presence of a `.env.example` file conventionally means "copy this to
`.env`, fill it in, and the tool picks it up automatically." That's not
true here. `daybook_post.py` is intentionally zero-dependency (no
`python-dotenv`, no manual `.env` parsing) and only reads real process
environment variables via `os.environ.get(...)` (confirmed: the only
`.env`-related string in the whole `skills/` tree is this example file
itself — nothing in `daybook_post.py` reads it).
Found while doing a first interactive posting session against the `test`
account from a sibling project (`AGENT_DAYBOOK__LLM_TEST`): copying
`.env.example` to `.env` and populating it did nothing; the script still
required the vars to be exported into the shell (or set with `set`/
`$env:` on Windows) for every new session/terminal. Neither `SKILL.md`
nor `docs/llm_posting_helper.md` mention this — both just say "use
environment variables" without clarifying that a `.env` file specifically
will not work unless the caller's shell/agent harness sources it itself.
This is minor but wastes a round trip for exactly the audience this skill
targets (an agent following the docs literally).
## Suggested Fix (pick one)
- Simplest: delete `.env.example`, or rename/annotate it clearly as "copy
these lines into your shell profile / CI secrets / agent harness env
config — this file itself is not read automatically."
- Alternative: add ~10 lines of manual `.env` parsing to `daybook_post.py`
(still zero third-party deps — just read `.env` in the working directory
if present, without overriding already-set real env vars) if the goal
really is per-project persistent config.
## Resolution
Took the simplest option: deleted `skills/daybook-posting/.env.example`
entirely rather than adding `.env` parsing to a helper that's deliberately
zero-dependency. Its content was already duplicated in both `SKILL.md` and
`docs/llm_posting_helper.md`'s Configuration sections, so nothing was lost.
Also reworded both of those sections to say explicitly that these must be
real process/shell environment variables — the helper reads `os.environ`
directly and has no `.env` file support — rather than just "use environment
variables" without ruling out the natural `.env`-file assumption.

View file

@ -0,0 +1,68 @@
# 003 — `CharField(max_length=...)` limits are silently unenforced on ingest
**Status:** Fixed
## Description
`artifacts/models.py` declares length caps on several `Artifact` fields —
`source` (80), `title` (200), `source_name` (120), `source_id` (120),
`theme` (40) — but nothing on the ingest path enforces them. `views.py`'s
`create_text_artifact`/`create_binary_artifact` call
`Artifact.objects.create(...)` directly, which does **not** run Django's
`full_clean()`/validators. SQLite (the DB in both dev and production per
`docs/deployment.md` line 197) also doesn't enforce `VARCHAR(n)` length as
a hard constraint on `INSERT`, so oversized values are written and read
back verbatim with no error at any layer.
Confirmed against the local `test` account:
1. `POST /api/artifacts` with a 250-character `title` (limit 200) →
`201`, title stored and returned in full, uncut.
2. `POST /api/artifacts` with a 150-character `source` (limit 80) →
`201`, `source` stored in full. Because `source_name` defaults to
`source` when omitted (`source_fields()` in `views.py`), this also
silently blew past `source_name`'s own 120-char limit in the same
request.
For contrast, other validation on the same endpoint works correctly and
returns a clean `400` — e.g. an invalid `tag_colors` hex value
(`{"tag_colors": {"urgent": "notacolor"}}` → `400` with a helpful
message). So the endpoint clearly has a validation convention; these
five fields just aren't part of it.
Practically: a title long enough could break feed-row layout in the UI;
`source` feeds directly into the on-disk storage path
(`artifacts_store/{source}/{day}/...`) and the sidebar/label system, so
an extreme value there has a larger blast radius than just cosmetics.
## Reproduction
```bash
curl -X POST http://127.0.0.1:8001/api/artifacts \
-H "Authorization: Bearer <test-account-token>" \
-H "Content-Type: application/json" \
-d "{\"source\":\"edge-case-test\",\"title\":\"$(python3 -c 'print("A"*250)')\",\"kind\":\"markdown\",\"content\":\"x\"}"
# -> 201, title returned uncut at 250 chars
```
## Suggested Fix
Add explicit length checks alongside the existing required-field/kind
checks in `ArtifactListCreateView.post` and `ArtifactDetailView.patch`
(and the two `create_*_artifact` helpers), returning `400` with a message
naming the field and its limit — consistent with how `tag_colors` and
`source_type` are already handled. A shared `validate_field_lengths(data)`
helper (mirroring `validate_source_fields`) covering `source`, `title`,
`source_name`, `source_id`, `theme` would keep it in one place.
## Resolution
Added `validate_field_lengths(data, fields)` in `artifacts/views.py`, called
from `validate_source_fields` (covers `source`, `title`, `source_name`,
`source_id`, `theme` on both text and binary `POST`, since both paths already
routed through that function) and directly in `ArtifactDetailView.patch`
(covers `title`, `source_name`, `source_id` — the only length-limited fields
`PATCH` can touch). Returns `400` naming the field and its limit, matching
the existing `tag_colors`/`source_type` convention. Tests added in
`tests/api/test_artifacts.py` covering oversized `title`/`source` on create,
an exact-limit boundary case, and oversized `title` on patch.

View file

@ -0,0 +1,71 @@
# 004 — `html_fragment`/`deck`/`html_full` content-shape rules exist only in the client helper, not the server
**Status:** Fixed
## Description
`docs/api.md` and `skills/daybook-posting/SKILL.md` both describe a
content-shape contract per text `kind`:
- `html_fragment` — "Wrapped with a Daybook theme" (body-only HTML, no
`doctype`/`html`/`body`).
- `html_full` — complete document, must contain `<html>`.
- `deck` — top-level `<section>` blocks.
`skills/daybook-posting/scripts/daybook_post.py`'s `validate_payload()`
enforces all three of these client-side before ever sending the request.
**`artifacts/views.py`'s `ArtifactListCreateView.post` / `create_text_artifact`
enforce none of them** — the only server-side checks are "kind is one of
the 4 text kinds" and "required fields present." Any client that talks to
`POST /api/artifacts` directly (curl, a different language, a
misconfigured agent) bypasses the contract entirely, and the server
accepts and stores whatever is sent.
Confirmed: posting `kind: "html_fragment"` with a full
`<!doctype html><html><head>...</head><body>...</body></html>` as
`content` returns `201`, and the stored page (`GET /a/{id}`) is:
```html
<!doctype html>
<html>
<head>
...
<title>nested html fragment</title>
<link rel="stylesheet" href=".../report.css">
</head>
<body class="artifact">
<!doctype html><html><head><title>Injected</title></head><body><h1>Nested doc</h1></body></html>
</body>
</html>
```
i.e. a doctype and a full `<html><head><body>` nested inside the theme
wrapper's own `<html><head><body>` — invalid markup that browsers will
silently "fix up" in unpredictable ways (duplicate/conflicting `<title>`,
nested `<head>`, etc.), rather than the clean, themed fragment the kind
is supposed to guarantee.
This is the same class of gap as #003: validation the docs promise is
implemented only in the optional client helper, not the server that's
supposed to be the actual contract enforcement point. Related to (but
distinct from) issue #003, which is about unenforced *length* limits
rather than unenforced *content shape*.
## Suggested Fix
Port `daybook_post.py`'s `validate_payload()` checks (or equivalent) into
`create_text_artifact`/`ArtifactListCreateView.post` server-side, returning
`400` with the same messages the helper already uses. This also closes the
gap for any future non-Python client.
## Resolution
Added `validate_content_shape(kind, content)` in `artifacts/views.py`,
ported directly from `daybook_post.py`'s `validate_payload()` (same rules,
same case-insensitive substring checks) so client and server agree: `deck`
requires `<section`, `html_fragment` rejects `<html`/`<!doctype`/`<body`,
`html_full` requires `<html`, and `markdown` rejects whitespace-only content
(which the old required-field check missed, since a non-empty
whitespace-only string is truthy). Called from `ArtifactListCreateView.post`
after the existing field/source validation. Tests added covering each
rejection case plus a well-formed `html_fragment` still succeeding.

View file

@ -0,0 +1,72 @@
# 005 — `PATCH /api/artifacts/{id}` silently mishandles invalid/immutable input instead of rejecting it
**Status:** Fixed
## Description
`ArtifactDetailView.patch` (`artifacts/views.py`) applies whatever
recognized keys are present in the request body without validating their
type or rejecting fields it can't act on. Two confirmed effects:
**1. A wrong-typed `tags` value silently wipes existing tags (data loss).**
`normalize_tags()` returns `[]` for anything that isn't a `str` or
`list`. `ArtifactDetailView.patch` calls it unconditionally whenever
`"tags"` is a key in the request body, with no type check first.
Reproduced:
```bash
# Artifact has tags ["important", "keep-me"]
curl -X PATCH .../api/artifacts/a74de286 -d '{"tags": 12345}' ...
# -> 200, tags silently become []
```
An agent that sends a malformed `tags` value (e.g. a stray int from a bad
template substitution) gets a `200` and loses the artifact's existing
tags with no indication anything went wrong.
**2. Immutable fields (`content`, `source`, `kind`, `day`) are silently
no-ops instead of rejected.**
`docs/api.md`'s "Not editable yet" table lists these fields and why, but
doesn't say what happens if a client sends them anyway. In practice:
`PATCH` with `{"content": "...", "source": "...", "kind": "...", "day":
"..."}` returns `200` with the artifact **unchanged** — the fields are
silently dropped, not rejected. An agent attempting to fix a typo in
`content` via `PATCH` (a very natural thing to try, since `PATCH` is the
only mutate-after-create endpoint) gets a success response that looks
like it worked but did nothing.
Both are instances of the same root cause: `patch()` doesn't validate
before applying, unlike `POST`'s `validate_source_fields()`.
## Suggested Fix
- Type-check `tags` (must be `str` or `list`) before calling
`normalize_tags`; return `400` on other types instead of silently
emptying it. Same principle likely applies to `metadata` (already
checked) and could apply to `source_prompt`/`source_name`/`source_id`
(currently accept anything via implicit str coercion via Django, but
worth a pass).
- Either make sending an immutable field (`content`/`source`/`kind`/`day`)
in a `PATCH` body a `400` ("field is not editable"), or explicitly
document that unknown/immutable keys are ignored — right now the
behavior is undocumented and easy to mistake for success.
## Resolution
Went with explicit rejection for both, over silent-ignore-and-document:
- `tags` is now type-checked (`str` or `list`) before `normalize_tags` runs;
any other type returns `400` and leaves existing tags untouched.
- Sending `content`, `source`, `kind`, or `day` in a `PATCH` body now returns
`400` naming the offending field(s), via a new `IMMUTABLE_PATCH_FIELDS`
check at the top of `ArtifactDetailView.patch`, before any other
processing — chosen over documenting the no-op because a `200` that does
nothing is easy to mistake for success.
`docs/api.md`'s "Not editable" table now says so explicitly instead of just
listing reasons. Tests added: wrong-typed `tags` is rejected and leaves
existing tags intact, and each of the four immutable fields is rejected
individually.

View file

@ -0,0 +1,81 @@
# 006 — A `POST` that ultimately fails with 413 still permanently deletes unkept artifacts
**Status:** Fixed
**Severity:** High — irreversible data loss on a failed request
## Description
`make_room_for()` (`artifacts/storage.py`) is called from `create_text_artifact`
/`create_binary_artifact` *before* the new artifact is persisted. When the
incoming artifact doesn't fit even after evicting every unkept artifact, it
raises `QuotaExceeded` and the view returns `413`. But the eviction loop
inside `make_room_for` deletes each unkept artifact **as it iterates**,
*before* it can know whether the eventual total will succeed:
```python
for artifact in unkept:
if used + incoming_bytes <= quota:
break
used -= artifact_total_bytes(artifact)
delete_artifact_files(artifact)
artifact.delete()
evicted.append(artifact)
if used + incoming_bytes > quota:
raise QuotaExceeded("Quota is full of kept artifacts; free space to post.")
```
If the loop runs out of unkept artifacts to evict and still hasn't freed
enough room (because the remaining `is_kept=True` artifacts alone exceed
the new/current quota), it raises *after* having already deleted every
unkept artifact — there's no rollback. The caller gets a `413` that reads
like "nothing happened, free up space and retry," but in reality **every
unkept artifact the user had was just destroyed** for a request that
still failed.
Reproduced against the `test` account (quota temporarily lowered to
demonstrate an unwinnable state — 1 kept artifact at 851 bytes vs. an
800-byte quota):
```
BEFORE: 1 kept artifact (851B) + 5 unkept artifacts (582/321/311/401/428B)
POST small markdown -> 413 "Quota is full of kept artifacts; free space to post."
AFTER: only the 1 kept artifact remains — all 5 unkept artifacts are gone
(both DB rows and on-disk files, confirmed via Django shell)
```
This is a normal, reachable state — not just an artificially shrunk quota.
Any user whose kept artifacts alone approach their quota will hit this the
moment they try to post anything that doesn't fit, and lose every unkept
artifact they had in the process, with a `413` response giving no
indication that a destructive side effect occurred.
## Suggested Fix
`make_room_for` should compute whether eviction *can* succeed before
deleting anything — e.g. sum `unkept` sizes up front and compare
`used - unkept_total + incoming_bytes` against `quota`; raise
`QuotaExceeded` immediately if even a hypothetical full eviction wouldn't
fit, without touching any artifact. Only perform actual deletions once
it's known they'll be sufficient. This makes the failure path side-effect
free, matching what a `413` response implies.
## Resolution
Fixed in `artifacts/storage.py`'s `make_room_for`: computes the total
recoverable bytes across all unkept artifacts up front and raises
`QuotaExceeded` immediately if even a full eviction wouldn't fit — no
artifact is touched on that path. The incremental eviction loop only runs
once success is already guaranteed.
While testing this fix, found and fixed a related latent bug: the function
returned the evicted `Artifact` *objects* after calling `.delete()` on them,
but Django clears an instance's pk on delete, so every returned object's
`.id` was silently `None`. Nothing currently reads this return value, so it
had no live impact, but it broke the function's own documented contract.
Now returns the evicted ids (captured before deletion) instead of objects.
Regression tests added in `tests/unit/test_storage.py`: an unwinnable
eviction leaves all unkept artifacts intact, a successful eviction only
evicts as many (oldest-first) as needed, and an over-quota single artifact
deletes nothing.