Merge remote-tracking branch 'wavelet/feat/cordis-alignment' into cordis

# Conflicts:
#	.agents/skills/cache-framework/SKILL.md
#	.agents/skills/clickhouse-batchwriter/SKILL.md
#	.agents/skills/database-migration/SKILL.md
#	.agents/skills/file-upload/SKILL.md
#	.agents/skills/logstore/SKILL.md
#	.agents/skills/new-api/SKILL.md
#	.agents/skills/new-api/references/handler_example.go
#	.agents/skills/new-api/references/logics_example.go
#	.agents/skills/new-api/references/service_example.go
#	.agents/skills/new-async-task/SKILL.md
#	.agents/skills/new-async-task/references/CODE-EXAMPLES.md
#	.agents/skills/new-setting/SKILL.md
#	.agents/skills/push-notification/SKILL.md
#	.agents/skills/release-guide/SKILL.md
#	.auto/checks.sh
#	.auto/ideas.md
#	.auto/log.jsonl
#	.auto/measure.sh
#	.auto/prompt.md
#	.dockerignore
#	.env.example
#	.github/copilot-instructions.md
#	.github/workflows/build-release.yml
#	.gitignore
#	.golangci.yml
#	AGENTS.md
#	Makefile
#	README.md
#	backend/cmd/app.go
#	backend/cmd/app_test.go
#	backend/cmd/banner.go
#	backend/cmd/banner_test.go
#	backend/docs/docs.go
#	backend/docs/swagger.json
#	backend/docs/swagger.yaml
#	backend/go.mod
#	backend/go.sum
#	backend/main.go
#	config.example.yaml
#	docker/Dockerfile
#	docker/Dockerfile.backend
#	docker/Dockerfile.cross
#	scripts/swagger.sh
#	scripts/update_go_license.sh
This commit is contained in:
ryan
2026-08-30 14:30:56 +08:00
48 changed files with 11116 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
# Autoresearch lessons — Wavelet / Cordis quality run
Accumulated wisdom across iterations. Read this before forming a hypothesis.
Weight recent lessons higher: the yardstick and codebase change under us.
## Lesson 1 — iterations 0-1
**Pattern**: The project's committed gate (`golangci-lint run` with `.golangci.yml`)
had already been driven to 0 issues by a previous run, so it could no longer
measure anything.
**Why it worked**: Measuring against a pinned snapshot + extra analyzers in
`.auto/lint.ref.yaml` (hash-locked by the Guard) restored headroom and made it
impossible to lower the number by editing the config.
**Conditions**: Any repo whose own lint gate is already green.
**Anti-pattern**: Optimising `tagliatelle` (325 findings) or `wrapcheck` (290).
Those are pure cosmetics — error-message wording and tag naming. A run that
chases them will look productive while shuffling strings.
**Metric delta**: baseline re-established at 102 instead of a dead 0.
## Lesson 2 — iterations 1-4
**Pattern**: Triage every analyzer finding for reality before "fixing" it.
**Why it worked**: Three buckets turned out to be false positives:
`forcetypeassert` in `core/events.go` is guarded by `returnsErr` (the handler's
declared last out really is `error`), and both `exhaustive` switches already
have `default:` arms — `exhaustive` only flags them because
`default-signifies-exhaustive` defaults to false.
**Conditions**: Always, but especially for linters whose defaults assume a
different project convention.
**Anti-pattern**: Adding `if !ok { ... }` branches or empty `case:` arms that
cannot execute. That raises the score and lowers the code.
**Metric delta**: 3 of 16 candidate linters dropped from the plan (0 gained,
real regressions avoided).
## Lesson 3 — iterations 1, 4
**Pattern**: Pair the metric drop with a mechanically provable defect: write the
regression test, commit, then revert *only* the source files and require the
test to fail (`.auto/prove_fix.sh`).
**Why it worked**: It caught a live bug that no counter measures — a
singleflight body capturing the first caller's request context, so one
disconnecting browser poisoned every concurrent request for that image.
Iteration 4 kept debt flat at 93 yet was the most valuable change so far.
**Conditions**: Every behavioural fix. A change that survives its own revert is
not a fix, it is a rename.
**Anti-pattern**: Calling something "hardening" without a test that fails
without it.
**Metric delta**: 0 for the proven bug (kept under the fix gate), 8 for the rest.
## Lesson 4 — iteration 5
**Pattern**: Strengthen the architecture gate; it is a generator of real,
previously invisible debt.
**Why it worked**: `check_cordis_architecture.sh` only grepped `go func(`, so
`go w.run()` — the shape used by four long-lived cleanup loops — passed
silently, each one able to take down the process on a panic. Widening the
pattern surfaced them immediately.
**Conditions**: Whenever a gate has been green for a long time. A green gate
proves the checks exist, not that they cover anything.
**Anti-pattern**: Weakening `.golangci.yml` (blocked outright by the Guard via
`check_gate_weaken.py` + a SHA lock on the yardstick).
**Metric delta**: 4 uncovered crash-on-panic sites hardened.
## Lesson 5 — iteration 3
**Pattern**: Deduplicate by extracting the shared *classification*, not the
shared *response*.
**Why it worked**: Two handlers mapped upload-lookup errors with copy-pasted
blocks that had quietly drifted (different fallback status, different synonym
constant for the same message). `filesrv.AbortUploadRecordError` handles the
200/400 branches, and each endpoint keeps its own fallback it can still
justify. Deleting the orphaned `ErrInvalidUploadID` constant was part of the
change, not extra cleanup.
**Conditions**: Duplicated error-mapping or validation blocks in sibling handlers.
**Anti-pattern**: Silently unifying HTTP status codes across endpoints to make a
helper fit — that is a behaviour change wearing a refactor's clothes.
**Metric delta**: -2.
## Lesson 6 — iterations 15-21
**Pattern**: Delegate a broad read-only audit for what mechanical gates cannot
see (N+1s, locks held over I/O, resource leaks, layering), then re-verify each
claim yourself before touching code.
**Why it worked**: The audit produced the run's best findings — the per-request
CORS database query, the orphan cron dispatching to a task nobody registered,
media temp dirs nothing ever removed. It also produced a wrong one: it asserted
telebot falls back to `http.DefaultClient` with no timeout, when telebot itself
constructs a client with a one minute deadline. Acting on that would have added
a tunable dressed up as a bug fix.
**Conditions**: Whenever the committed gates are green and the easy signal is
exhausted.
**Anti-pattern**: Trusting an audit summary's file:line as evidence. One
referenced file did not exist.
**Metric delta**: 0 for three landed fixes (all kept under the proven-fix gate),
but they were the run's highest-impact changes.
## Lesson 7 — iteration 16
**Pattern**: Prove query-reduction with a functional test double that counts
loader invocations, and assert the counter for both the batch and the looped
form in the same test.
**Why it worked**: Asserting "1 query" alone is vacuous — it also passes when
nothing ran. Asserting batch=1 and per-id=3 in one test makes the instrument
itself checked, so the claim cannot silently degrade.
**Conditions**: Any change whose whole value is doing less I/O.
**Anti-pattern**: Fixing an N+1 by reaching around the contract into another
plugin's repository. The layering was the reason the slow path existed; the
right move was to extend the contract with a batch method.
**Metric delta**: 0 (kept under the proven-fix gate).
## Lesson 8 — iterations 17-22
**Pattern**: Strengthen a gate only alongside the code that satisfies it, and
never rewrite history in a shared worktree.
**Why it worked**: Deleting 24 dead lint suppressions paid off exactly as the
self-correcting design predicted: two of them were load-bearing under the
project's own gate even though the analyzer called them unused, the Guard
vetoed, and their removal surfaced two verified `contextcheck` false positives
worth documenting instead of silently swallowing. Meanwhile a concurrent
session was committing plan documents in the same tree, so `git add -A` swept
one of its in-flight edits into my commit — unfixable by rebase without
destroying their work, so the repair was to stage explicit paths from then on.
**Conditions**: Always, in this repo. Assume another agent is editing `docs/`
and `backend/core` concurrently.
**Anti-pattern**: `git add -A` outside the first setup commit. Also: trusting
"unused directive" as "safe to delete" — check the strictest config, not just
the pinned yardstick.
**Metric delta**: -25 in one iteration.
## Lesson 9 — iterations 23-24
**Pattern**: Cross-check every service a plugin's `Apply` reads out of the
container against what that plugin's `Inject()` declares. `Inject()` is the only
thing `App.reconcileLocked` gates on, so anything consumed as a *value* at Apply
time but left undeclared is resolved from a container that may not hold it yet.
**Why it worked**: It found the run's worst defect, invisible to every
mechanical gate: `user` declared only `DBService` while capturing
`contracts.AuthService` to build its route guard, and `cmd/app.go` lists `user`
before `auth`. Because user's dep set is a strict subset of auth's and it sits
earlier in the slice, user *always* mounts first — deterministically, not a
race — so `loginMW` fell back to a `c.Next()` closure and
`/api/v1/user/{change-password,profile,access-tokens}` mounted unguarded. The
same lookups in `admin` read a package global that its own `OnDispose` nils, so
in-flight requests fail open during dispose.
**Conditions**: Any Cordis plugin whose Apply assigns a contract result to a
variable used later (middleware, handler closures). Services bound through
`core.When` late binding are exempt — that is the correct pattern for genuinely
late deps, so do not blanket-declare everything.
**Anti-pattern**: Assuming a checked `x, ok :=` assertion is safe. All three
plugins used the checked form and all three failed *open* — checked syntax,
unchecked semantics.
**Metric delta**: 0 across both iterations (kept under the proven-fix gate),
but this is the run's highest-severity finding. `RouterRegistry` records each
route's `Handlers`/`Middlewares`, which makes "is this route actually guarded?"
directly assertable from the route table — the cheapest available oracle for
security properties here.
## Lesson 10 — iteration 23 review
**Pattern**: When the remaining metric is dominated by a positional or
taste-based analyzer, say so and refuse to spend iterations on it.
**Why it worked**: `funcorder` was 21 of 54 findings (39%) — pure function
*ordering within a file*. Reordering private helpers to the bottom of a file
moves the number and changes nothing a reader or the machine cares about, which
is Lesson 1's "looks productive while shuffling strings" with a different label.
Skipping it kept the loop honest. Triage also cleared 12 of 13
`forcetypeassert` (guarded by construction) and 2 of 3 `unparam` (deliberate
constructor symmetry behind one factory switch).
**Conditions**: Whenever one linter dominates a shrinking total, break the count
down per linter *before* picking a hypothesis.
**Anti-pattern**: Treating a large single-linter share as an easy win. Real
headroom at this point is ~10 findings, so a plateau in `debt` no longer means a
stalled loop.
**Metric delta**: 0 spent, ~21 findings deliberately left in place.
## Lesson 11 — iterations 24-25
**Pattern**: Run the Guard after every single commit, and confirm which commit a
proof script is actually reverting against.
**Why it worked**: Four `staticcheck ST1023` findings from iteration 24 shipped
straight through `go build ./...` and a green 47-package `go test ./...` —
neither runs the project linter, so only `checks.sh` section 3 catches them.
Separately, `prove_fix.sh` reverts to `HEAD^`; appending the iteration-23 log
commit shifted `HEAD^` to the *fixed* state and reported "PROVE FAILED: tests
still pass without the fix" on a genuinely load-bearing fix. Re-checking against
the explicit pre-fix commit (`git checkout <sha> -- <files>`) showed the real
answer. A false negative here is worse than no proof: it reads like the fix was
cosmetic.
**Conditions**: Always. Also note zsh does not word-split unquoted variables, so
`git checkout $FILES` passes one bogus pathspec and silently reverts nothing —
the command still exits 0.
**Anti-pattern**: Batch-verifying at ship time. And any shell loop built on the
bash word-splitting habit in this environment.
**Metric delta**: -0, 1 wrong verdict corrected.
## Lesson 12 — iterations 27-32
**Pattern**: Two things produced every substantive win: (1) find a place where
correctness rests on a *prose comment* instead of an enforced constraint, and (2)
find immutable startup work being redone inside a request path.
**Why it worked**: Lint cannot see either class, so `debt` barely moved while real
defects did. The comment "field comes from call sites, never from user input" sat
on a function that interpolated its column argument straight into `WHERE` — the
tautology payload executed and returned a row with `err=<nil>`, a filter bypass,
not a hypothetical. The comment "contracts are pure abstractions" sat on a DTO
carrying `TableName()`, which is exactly the handle four plugins used to read
`w_users` instead of calling `UserService`. On the second pattern, three packages
each re-normalised and re-split static whitelist patterns per request: hoisting
that to registration cut 14 allocs/op to 1.
**Conditions**: Any exported function taking a string that reaches SQL, a path
matcher, or a shell. Any loop over configuration inside a request handler.
**Anti-pattern**: Believing `nolintlint`'s "unused directive" means "safe to
delete" — hit twice now, and the project gate vetoed it both times. Also believing
a doc comment's self-assessment: verify the claim or leave it alone.
**Metric delta**: 64 -> 63 across five keeps. Four of the five kept changes had
delta 0. Under a pure-debt loop this run would have looked stalled while fixing a
security bypass and a hot-path allocation bug.
## Standing notes
- **The golangci-lint cache is machine-wide** (`~/.cache/golangci-lint`), so a
sibling worktree analysing identical sources replays here carrying *that*
checkout's absolute paths — 12 of 63 findings pointed outside the repo, which
misattributes findings and can serve a stale Guard verdict. `measure.sh` and
`checks.sh` now key `GOLANGCI_LINT_CACHE` per checkout (iteration 31). It is
count-neutral (cold and warm both 63), but check path attribution before
trusting any finding's location.
- **Do not delegate a repo-wide audit to one subagent.** Both broad audits
(architecture, bugs/perf) hit the 150-turn cap after ~45M tokens combined and
returned nothing usable. Everything this run found came from targeted inline
greps followed by reading the specific function. If delegating, bound it to one
package cluster and a small finding budget.
- Run decisions for this run: real defects first with `debt` as a secondary gate,
commits directly on `main`, small file moves allowed but large package
restructuring goes to a written proposal first.
- Upstream moves fast in this repo: `origin/main` gained 11 commits mid-run
(Cordis config extension point — `ctx.Config().Bind`, `DeclareConfig()`,
`core.ConfigGatedPlugin`), which raised measured `debt` 54 -> 64 and
`nolint_dirs` 72 -> 73 on its own. Rebase early and re-run the Guard after;
a clean rebase does not mean a green one.
- `cmd.TestNewWaveletAppWithRedisEnabled` needs a live Redis on
`127.0.0.1:6379` and fails without one. Pre-existing on `origin/main`, so
`tests_passed` 46 vs 47 is environmental, not a regression. Confirm against a
scratch `git worktree` of `origin/main` before blaming a change for it.
- Repo facts: backend module rooted at `backend/`, gofumpt orders a single
import group as `Wavelet/...` before stdlib (uppercase sorts first); new Go
files need the Apache license header or `scripts/update_go_license.sh --check`
fails the Guard.
- Handler edits require `make swagger` (cheap: it regenerates identical docs
when only bodies change).
- Dead suppressions are tracked by the `nolint_dirs` counter; removing one that
is still needed re-raises the original finding, so the metric self-corrects.
24 were removed in iteration 22; 72 remain, each still doing work (73 after
the upstream rebase).
+12
View File
@@ -0,0 +1,12 @@
# Autoresearch baseline — captured at iteration #0 (2026-08-29).
# The Guard compares live values against these floors; the PRIMARY metric is debt.
BASE_DEBT=102
BASE_NOLINT=96
BASE_TESTS_PASSED=46
BASE_TEST_FUNCS=232
BASE_TEST_FILES=76
BASE_ARCH_VIOL=0
BASE_COVERAGE=34.09
# SHA-256 of the pinned yardstick config. Guard aborts if it changes.
REF_SHA=e881bda167bd688489f1356b7cd4056b8a6960f48b6778b095bdd2e44f627b82
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Anti-cheat: prove .golangci.yml was only ever strengthened, never weakened.
Compares the live gate against the immutable snapshot taken at run start.
Exits non-zero with a reason if any hardening rule is violated.
"""
import os
import sys
import yaml
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASELINE = os.path.join(ROOT, ".auto", "gate.baseline.yml")
LIVE = os.path.join(ROOT, ".golangci.yml")
# threshold-like knobs: (path, direction) where direction "max" means the value
# is an upper bound (smaller == stricter), "min" means a lower bound.
STRICTNESS = [
(("linters", "settings", "dupl", "threshold"), "max"),
(("linters", "settings", "cyclop", "max-complexity"), "max"),
(("linters", "settings", "cyclop", "package-average"), "max"),
(("linters", "settings", "nestif", "min-complexity"), "min"),
(("linters", "settings", "funlen", "lines"), "max"),
(("linters", "settings", "funlen", "statements"), "max"),
(("linters", "settings", "gocyclo", "min-complexity"), "min"),
(("linters", "settings", "lll", "line-length"), "max"),
]
def load(path):
with open(path, encoding="utf-8") as fh:
return yaml.safe_load(fh) or {}
def dig(doc, path):
node = doc
for key in path:
if not isinstance(node, dict) or key not in node:
return None
node = node[key]
return node
def enabled_linters(doc):
lint = doc.get("linters") or {}
if lint.get("enable-presets"):
return None # preset based; fall back to "any removal is suspicious"
return set(lint.get("enable") or [])
def main():
try:
base, live = load(BASELINE), load(LIVE)
except OSError as exc:
print(f"gate snapshot unreadable: {exc}")
return 1
except yaml.YAMLError as exc:
print(f".golangci.yml is not parseable: {exc}")
return 1
problems = []
base_lint, live_lint = base.get("linters") or {}, live.get("linters") or {}
if (base_lint.get("default") or "none") != (live_lint.get("default") or "none"):
problems.append("linters.default changed")
base_set, live_set = enabled_linters(base), enabled_linters(live)
if base_set is None or live_set is None:
if set((base.get("linters") or {}).get("enable-presets") or []) - set(
(live.get("linters") or {}).get("enable-presets") or []
):
problems.append("an enable-preset was removed")
elif dropped := base_set - live_set:
problems.append(f"linters disabled: {sorted(dropped)}")
for path, direction in STRICTNESS:
old, new = dig(base, path), dig(live, path)
if old is None or new is None:
continue
try:
old_f, new_f = float(old), float(new)
except (TypeError, ValueError):
continue
if direction == "max" and new_f > old_f:
problems.append(f"{'.'.join(path)} loosened {old} -> {new}")
if direction == "min" and new_f < old_f:
problems.append(f"{'.'.join(path)} loosened {old} -> {new}")
base_mnd = set(dig(base, ("linters", "settings", "mnd", "checks")) or [])
live_mnd = set(dig(live, ("linters", "settings", "mnd", "checks")) or [])
if base_mnd - live_mnd:
problems.append(f"mnd checks dropped: {sorted(base_mnd - live_mnd)}")
issues_live = live.get("issues") or {}
for key in ("exclude-rules", "exclude-patterns"):
if issues_live.get(key) and not (base.get("issues") or {}).get(key):
problems.append(f"issues.{key} added (suppresses reporting)")
for key in ("max-issues-per-linter", "max-same-issues"):
old = (base.get("issues") or {}).get(key)
new = issues_live.get(key)
if old == 0 and new != 0:
problems.append(f"issues.{key} no longer 0 — findings would be truncated")
# Exclusions expressed through the newer 'linters.exclusions' block.
if (live_lint.get("exclusions") or {}) and not (base_lint.get("exclusions") or {}):
problems.append("linters.exclusions added")
if problems:
print("\n".join(f" - {p}" for p in problems))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+61
View File
@@ -0,0 +1,61 @@
version: "2"
run:
timeout: 5m
tests: false
linters:
default: none
enable:
# 基础检查
- govet
- staticcheck
- errcheck
- ineffassign
- unused
# 代码坏味道
- dupl # 重复代码
- mnd # 魔法数字
- goconst # 不必要的字符串常量
- cyclop # 包/函数复杂度
- nestif # if 嵌套太深
- maintidx # 维护性指数
- revive # 风格/命名/坏味道
- gocritic # 各类代码问题
- funlen # 函数过长
- gosec # 安全问题检查
- bodyclose # HTTP response body 没有正确关闭
- noctx # 没有传递 context.Context
- contextcheck # 其他检查
- sqlclosecheck # SQL rows 没有正确关闭
- unconvert # 不必要的类型转换
- nilerr # 函数返回 nil 错误
settings:
dupl:
threshold: 80
cyclop:
max-complexity: 20
package-average: 10
nestif:
min-complexity: 5
funlen:
lines: 200
statements: 100
mnd:
checks:
- argument
- condition
- return
# 完整上报所有问题(取消 golangci 默认 50/3 截断,保证 code-check 与度量真实)
issues:
max-issues-per-linter: 0
max-same-issues: 0
+78
View File
@@ -0,0 +1,78 @@
version: "2"
# Pinned autoresearch yardstick. IMMUTABLE for the duration of a run.
# Snapshot of the committed .golangci.yml plus the extra analyzers that report
# genuine defects (correctness / panics / dead code) rather than cosmetics.
# Keeping this separate from .golangci.yml means strengthening the project gate
# can never silently lower the measured debt.
run:
timeout: 5m
tests: false
linters:
default: none
enable:
# --- from the committed project gate ---
- govet
- staticcheck
- errcheck
- ineffassign
- unused
- dupl
- mnd
- goconst
- cyclop
- nestif
- maintidx
- revive
- gocritic
- funlen
- gosec
- bodyclose
- noctx
- contextcheck
- sqlclosecheck
- unconvert
- nilerr
# --- extra real-risk analyzers (defects, not cosmetics) ---
- errorlint # err == / %v instead of errors.Is/As and %w
- forcetypeassert # unchecked type assertions can panic
- nilnil # (value, nil) breaks the nil-check contract
- predeclared # shadowing builtins
- unparam # dead params/results
- wastedassign # dead stores
- exhaustive # enum switches missing cases
- makezero # append to preallocated slice
- rowserrcheck # sql.Rows error after iteration
- durationcheck # multiplied time.Duration
- prealloc # slice growth in loops
- copyloopvar # loop-var capture
- nonamedreturns
- funcorder # struct methods scattered across files
- nolintlint # suppression audit (must stay 0)
settings:
dupl:
threshold: 80
cyclop:
max-complexity: 20
package-average: 10
nestif:
min-complexity: 5
funlen:
lines: 200
statements: 100
mnd:
checks:
- argument
- condition
- return
issues:
max-issues-per-linter: 0
max-same-issues: 0
+129
View File
@@ -0,0 +1,129 @@
# Deferred proposals — autoresearch run (iterations 27-36)
Five verified findings deliberately **not** changed by the loop: each needs either a
contract/API decision or a multi-package restructure, which this run was scoped to
propose rather than perform. Evidence is from reading the cited files in this
checkout at commit `ea97b64` plus iterations 27-35.
---
## P1 — Cross-driver storage migration cannot move objects (severity: data availability)
`plugins/domain/upload/task/storage_migration.go` computes `target` from the payload,
then calls:
```go
migrated, err := migrateObjects(ctx, storageSvc, storageSvc, total)
```
`sourceBackend` and `targetBackend` are the **same** `contracts.StorageService`. That
service resolves its backend per call and only ever to the currently active one
(`plugins/infra/storage/plugin.go:89` → `s.backend` or `objectstore.Active(ctx)`), and
the target config is persisted **after** the migration loop
(`uploadstorage.SaveActiveConfig(ctx, target)`).
Consequence for a non-empty source: `migrateSingleObject` reads and writes the same
backend; `shouldSkipMigration` finds every object already "present in the target" and
skips it, yet `migrated` is still incremented, so the task returns
`存储迁移完成,共迁移 N 个对象,活动存储已切换为 <driver>` having copied **zero** bytes,
and then points the platform at an empty backend. The same-driver and
`total == 0` branches are harmless and legitimately need no copying.
Why the tests miss it: `shared.MockStorageService` is one instance serving both
parameters, so a copy-to-self looks correct.
Proposed fix (needs a contract decision — this is a feature, not a patch):
1. Extend `contracts.StorageService` with the ability to operate against an explicitly
supplied `StorageConfigDTO` (e.g. `BackendFor(ctx, cfg) (StorageReader, error)`),
implemented in `plugins/infra/storage` where the `objectstore` backends live. They
are unexported today and `plugins/domain/upload` must not import them (cross-plugin
import ban), so the contract is the only correct route.
2. In the task, build the target from `target` and pass distinct source/target.
3. Only save the active config after a verified copy, and assert `src != dst` at
entry.
4. Interim safety option if a decision is needed sooner: make the
`target.Driver != active.Driver && total > 0` branch return an explicit
not-implemented error instead of reporting success. Rejected by this loop because
it disables an advertised admin operation, which is a product call, and because
the machinery it would strand (`migrateObjects`, `migrateSingleObject`,
`shouldSkipMigration`) becomes dead code the project gate then rejects.
Size: contract + infra impl + task wiring + a two-backend test double. Roughly one
focused session, not a loop iteration.
---
## P2 — `w_system_configs` has one migration owner and many writers (Cordis single-owner)
Owner per migrations: `plugins/domain/admin`. Still read/written with raw SQL from
`plugins/domain/system/repository.go:31`, `plugins/domain/cap/repository.go:50`,
`plugins/domain/auth/repository.go:122`, `plugins/domain/upload/storage/migration.go:96,108`,
`plugins/domain/upload/ingest/helpers.go:55`,
`plugins/domain/message_gateway/repository/push.go` and `plugins/drivers/driver_http`.
Iteration 34 fixed one instance of the real damage this causes (a failed read looked
identical to "unconfigured", silently dropping notifications); iteration 35 fixed
another (a failed read cached a narrowed whitelist for a whole TTL). The remaining
sites carry the same trap.
Proposed fix: one settings accessor contract (`Get(ctx, key) (string, error)` /
`GetAll(ctx, keys...)`) owned by the settings subsystem, then delete the raw table
access. Keys should be declared where they are used rather than string-matched.
Size: medium, touches seven plugins; do it key-group by key-group so each step is
independently revertable.
---
## P3 — Two tables are modelled twice (schema drift hazard)
* `w_task_executions`: `plugins/domain/admin/model/entity.go:207` **and**
`plugins/drivers/driver_asynq_worker/types.go:49`. The two `TaskExecution` structs
and their status enums are byte-for-byte identical today.
* `w_schedules`: `plugins/domain/admin/model/entity.go:169` **and**
`plugins/drivers/driver_asynq_cron/schedule.go:25`.
Nothing is broken yet — that is the risk: the migration owner was only recently moved
to `admin` (`49f9d10`), and a column added to one struct will silently diverge from
the other, so whichever writer holds the stale struct zeroes or omits the new column.
Proposed fix: pick the single owner per P2's rules and have the other side go through
a contract (execution recording already has DTOs in `contracts`), then delete the
duplicate model. Consider a gate check rejecting two non-`testhelper` packages
declaring the same `w_` table — it will fail until these two are resolved, so land it
with the fix (the pattern that worked in iterations 5 and 19).
---
## P4 — `user` deletes rows from tables owned by `auth`
`plugins/domain/user/repository.go:327,330` issues `DELETE` against `w_access_tokens`
and `w_external_accounts`, both owned and migrated by `plugins/domain/auth`, inside
user deletion. It works, but ownership is inverted: revoke-on-delete is auth's
invariant, and encoding it in `user` means any other deletion path silently skips it.
Proposed fix: emit a typed `user:deleted` event from `user` and let `auth` cascade
within its own transaction boundary, or expose an explicit `AuthService.RevokeForUser`.
Size: small-to-medium; needs a test that the revocation still happens on delete.
---
## P5 — Package `cap` shadows the predeclared identifier (8 of 62 debt)
Every file in `plugins/domain/cap` declares `package cap`, which shadows the builtin.
It is the single largest block of non-cosmetic lint debt this run declined to chase,
and it is also a readability cost (`cap.Something` reads as a builtin call).
Proposed fix: rename to a non-shadowing identifier (e.g. `capacity` / `proofwork`,
matching what the plugin actually does) across its own files and importers. Mechanical
but wide; needs a decision on the new name first, which is why it is not done here.
---
## Explicitly rejected as metric-chasing
23 `funcorder`, 5 `exhaustive` (both flagged only because
`default-signifies-exhaustive` defaults to false), 4 `nonamedreturns` and the 17
`forcetypeassert` cluster in `core/events.go` and `core/extpoints/config_resolve.go`
— verified guarded by construction (`convertString` etc. return `(any, error)` and
always yield the asserted type when `err == nil`). Reordering functions or adding
unreachable `if !ok` branches would raise the score and lower the code.
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# Mechanically prove a FIX iteration is load-bearing.
#
# Usage: .auto/prove_fix.sh <package> <changed source file> [<more files>...]
#
# Run immediately AFTER committing the fix, with a clean worktree. It reverts
# only the non-test source files to their pre-fix state (keeping the new test),
# runs the package tests, and requires them to FAIL. Then it restores HEAD.
# A fix nobody can break with a revert is not a fix.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
if [ ! -z "$(git -C "${ROOT}" status --porcelain)" ]; then
echo "PROVE ABORT: worktree must be clean (commit the change first)"
exit 2
fi
PKG="$1"; shift
SRC_FILES=("$@")
if [ "${#SRC_FILES[@]}" -eq 0 ]; then
echo "PROVE ABORT: no source files given"
exit 2
fi
cd "${ROOT}/backend" || exit 2
restore() {
git -C "${ROOT}" checkout HEAD -- "${SRC_FILES[@]}" 2>/dev/null
}
trap restore EXIT
for f in "${SRC_FILES[@]}"; do
if git -C "${ROOT}" cat-file -e "HEAD^:${f}" 2>/dev/null; then
git -C "${ROOT}" checkout "HEAD^" -- "${f}" || { echo "PROVE ABORT: cannot revert ${f}"; exit 2; }
else
# File did not exist before this commit — removing it is the revert.
rm -f "${ROOT}/${f}"
fi
done
echo "--- tests against pre-fix source ---"
OUT=$(go test -count=1 "${PKG}" 2>&1)
RC=$?
echo "${OUT}" | tail -15
if [ "${RC}" -eq 0 ]; then
echo "PROVE FAILED: tests still pass without the fix — this is not a real bug fix"
exit 1
fi
if echo "${OUT}" | grep -q 'build failed'; then
KIND="compile (signature changed; behaviour proven by inspection)"
elif echo "${OUT}" | grep -qE '^--- FAIL'; then
KIND="assertion"
else
KIND="failure"
fi
echo "PROVED: test fails without the fix (${KIND})"
exit 0
+37
View File
@@ -0,0 +1,37 @@
iteration commit metric delta status guard description
0 - 102 0.0 baseline pass initial measurement (pinned yardstick: repo gate + real-risk analyzers)
1 1c5731b 100 -2.0 keep pass core: Using2/Using3 now wrap dependency causes via errors.Join (proven: test fails on revert)
2 37ad586 95 -5.0 keep pass sentinel == comparisons -> errors.Is across admin/upload/cap-pow (5 sites)
3 686e3ef 93 -2.0 keep pass filesrv.AbortUploadRecordError dedups error mapping + errors.As (2 sites, drops dead ErrInvalidUploadID)
4 7e6b9e7 93 0.0 keep pass PROVEN FIX: singleflight image generation no longer dies with the first caller canceled ctx (test fails on revert)
5 381c794 93 0.0 keep pass CORDIS: gate widened to catch bare "go call()" + 4 unprotected cleanup goroutines moved to util.Go (arch violations 4->0)
6 ce33997 92 -1.0 keep pass BUGFIX admin logs: negative cursor was accepted (bool ignored by callers) -> error-only contract; proven via revert (compile-level) + contract test
7 c66399e 89 -3.0 keep pass push channels share title/content/level extraction (3 dead inits gone, ~20 fewer lines)
8 18820b1 89 0.0 keep pass BUGFIX push: synthesized notification content had random field order (map iteration); sorted keys, test observed failing pre-fix
9 22ecafd 87 -2.0 keep pass unparam: always-nil error returns dropped, 4 unreachable branches removed
10 c4068ef 84 -3.0 keep pass errorlint cleared to 0: %%w at push test + telegram fallback, errors.As in config loader
11 3d2038a 80 -4.0 keep pass nilnil: unimplemented auth mocks now return a sentinel instead of (nil,nil)
12 101cb2f 79 -1.0 keep pass nilnil: inproc driver GetExecution returns error, matching asynq driver semantics
14 6932b54 79 0.0 keep pass DATA-LOSS BUGFIX: cache read error no longer clobbers buffered task log (proven: assertion fails on revert)
15 2c41563 79 0.0 keep pass PERF: CORS origin check no longer hits DB per request (5s cached read); proven - loader count 0 vs 1 on revert
16 976f9b1 79 0.0 keep pass PERF: contract-level batch user lookup replaces N+1 in access-log enrichment (test proves 1 query vs 3)
17 1b1c452 79 0.0 keep pass BUGFIX: orphan cron message_gateway:cleanup_pairing_codes now has a handler; invariant test added (proven by stash-revert)
18 8c4955c 79 0.0 keep pass BUGFIX: removed phantom user:daily_audit cron (dispatched to unregistered task); cross-plugin invariant test added
19 84eaf3f 79 0.0 keep pass CORDIS+BUGFIX: task handlers were asynq-typed so 4 upload tasks could not run under the in-process worker; made driver-agnostic + gate check 7 (proven: gate names all 3 files pre-fix)
20 efa7555 79 0.0 keep pass BUGFIX telegram: LongPoller.Timeout was 10 nanoseconds -> getUpdates timeout=0 -> busy polling; now 10s (proven by reverting the constant)
21 1023fa3 79 0.0 keep pass DISK LEAK: telegram inbound media scratch dirs were never removed (no consumer reads them); cleanup on handler exit. No test possible (needs live download)
22 ad83841 54 -25.0 keep pass dead lint suppressions removed (24); 2 were load-bearing -> restored+narrowed with reasons after guard veto exposed verified contextcheck FPs
23 de938de 54 0.0 keep pass SECURITY/BUGFIX fail-open auth: user+message_gateway consumed contracts.AuthService in Apply but declared only DBService, so reconcile mounted user before auth and loginMW degraded to a pass-through (user change-password/profile/access-tokens unguarded in production, deterministically); declared the dep + added reconcile-level ordering test (PROVED: assertion fails on revert)
24 62b48e9 54 0.0 keep pass SECURITY: all three auth-middleware fallbacks were c.Next() (fail-open). Reachable at runtime in admin: OnDispose->ResetServices() nils the global the per-request guard reads, so in-flight requests pass as authenticated. Added ginutil.AuthUnavailable() + table test driving each registered guard (PROVED: abort assertion fails on revert to 577d795)
25 f58f5a4 54 0.0 keep pass staticcheck ST1023 x4 from iter 24 (redundant gin.HandlerFunc on typed-RHS decls) - caught by GUARD only, go build/go test both stayed green; lesson: run checks.sh after EVERY commit, not just before ship
26 - 64 +10.0 rebaseline pass upstream config-extension + auth/user/task work raised debt 54->64; re-measured at HEAD ea97b64, 47 pkgs pass, arch 0 viol. Run focus agreed: real defects primary, debt secondary (proven-fix gate keeps delta-0 fixes)
27 31f3af6 63 -1.0 keep pass dead contextcheck suppression on cmd.newWaveletApp removed; the core.App.Run one was load-bearing (guard veto: project gate contextcheck Run->Start, verified FP on variadic ctx) -> restored narrowed + documented. Lesson 8 trap re-hit: nolintlint "unused" != safe to delete
28 5037097 63 0.0 discard fail CORDIS gate: contracts DTO must not carry TableName + removed UserDTO.TableName(). DISCARDED: my grep used -g !*_test.go and missed upload/handler/routers_test.go:669 which does db.Create(&contracts.UserDTO{}) into w_users - that suppression exists precisely to enable the cross-plugin write. Lesson: contracts-purity changes must scan test files too.
29 2ff0cb8 63 0.0 keep pass CORDIS contracts purity: gate check 2.2 forbids TableName()/gorm tags in core/contracts + removed UserDTO.TableName(); upload/handler test now seeds via explicit .Table("w_users") (precedent: filesrv test). Proven twice over: gate named auth.go:33 pre-fix, and iter-28 revert broke 1 package without the test fix. Delta-0 keep under the agreed real-defect gate
30 9ea0e2b 63 0.0 keep pass SECURITY (assertion-proven): FindUserByFieldRecord interpolated its column arg into WHERE with only a prose comment as guard. Pre-fix the tautology "username = '' OR 1=1 --" EXECUTED and returned a row with err=<nil> (filter bypass). Now an allow-list rejects before GetDB. Also first test in the repository pkg: tests_passed 47->48, funcs 282
31 5193bd0 63 0.0 keep pass HARNESS INTEGRITY: measure.sh and checks.sh now key GOLANGCI_LINT_CACHE per checkout. The default cache is machine-wide, so entries written by a sibling worktree replayed here carrying ITS absolute paths (12 of 63 lines pointed at an outside checkout), misattributing findings and risking a stale Guard verdict. Proven count-neutral: cold and warm both 63; foreign paths now 0. Delta 0 by design, kept under the agreed real-defect gate
32 f7a86d3 63 0.0 keep pass PERF+DEDUP: three packages hand-rolled mutex+[]string+MatchPathPattern loop, re-normalising and re-splitting immutable patterns per request. New extpoints.PathWhitelist compiles patterns at registration and absorbs all three. Mechanically asserted: 14 allocs/op -> 1 allocs/op; equivalence test pins Match against the legacy loop over a full pattern x path matrix; race-clean. funcs 282->288, coverage 35.02
33 99fca9e 62 -1.0 keep pass BUGFIX+DEDUP: task.loadActiveStorageConfig and saveActiveStorageConfig duplicated uploadstorage.LoadStorageConfig/SaveActiveConfig but swallowed all three failures (nil db, read error, json parse) returning zero config + nil error, making the caller-s already-written error branch dead: a storage migration could run from an unknown active driver. Now routed through the canonical accessors; regression test corrupts the stored config and asserts Execute errors (assertion-proven via revert). funcs 289
34 b22f863 62 0.0 keep pass BUGFIX+PERF: LoadSMTPConfigRecord fired four single-key queries and discarded every error with underscore assignment, so an unreadable w_system_configs returned four blank strings both callers could only read as "SMTP not configured" -> notification silently dropped. Now one IN query plus a real error channel; callers log at the boundary and keep their own values. Proof is signature-level and exact: the old API had no error return, so the failure was unrepresentable. 4 queries -> 1, funcs 291
35 d7c851b 62 0.0 keep pass BUGFIX+PERF: access_cache discarded the whitelist read error with underscore assignment, then unconditionally set valid=true and CheckedAt=now, so one transient DB failure pinned the RESTRICTED default public-access list for the whole TTL and silently narrowed an admin-configured whitelist. Now the error is logged, last-good is served when known, and a cold failure stays invalid so the next request retries. Assertion-proven by dropping and restoring the table mid-test. funcs 292
36 - 62 0.0 keep skip PROPOSALS (.auto/proposals.md): five verified items deliberately not auto-fixed per the agreed scope. Headline P1: cross-driver storage migration passes the SAME service as source and target (getBackend only ever resolves the active backend) and saves the target config afterwards, so it reports "migrated N objects" having copied zero bytes and then points the platform at an empty backend. Needs a contracts.StorageService capability decision, not a patch.
Can't render this file because it contains an unexpected character in line 7 and column 63.