--- name: database-ops description: 'Use BEFORE the first read or write against any non-local database, however small: a `SELECT` against production, any SQL run by hand, a single-row lookup, a support investigation touching live rows, a data fix, a schema or migration check, an export, or writing up what was done afterwards. Covers reading the prior cases first, the host-and-credential step (secret store, read or write), the dry-run-and-approval gate on every INSERT/UPDATE/DELETE, the dated case folder recording the exact query and its observed output, host identity verification, and what is kept or deleted when the work is finished. MANDATORY, never optional — "quick lookup", "obvious fix", "just a SELECT", or "it is read-only anyway" is the exact trap: those are the queries that reach production with no record and an unverified host. If the task touches live or production data at all, load this FIRST. Requires database-mapping before any operation that needs to know which table holds what.' --- # Database Ops ``` MANDATORY — before the first statement against any non-local database, and before any clarifying question: 1. READ the cases this project already has. A prior correction can change what you are about to run, which is why it comes first. 2. VERIFY the host as its own command, and read the answer back. 3. NAME where the credential came from. The secret store, or stop. 4. DECLARE read or write, now rather than after the statement is written. 5. OPEN the case folder — `YYYY-MM-DD-/case.md` — and record 1–4 in it before running anything. Any one of the five you cannot complete means you are not ready to run a statement. "Quick lookup" does not shorten this list. ``` ## Overview This skill is the discipline for operating on data that other people depend on. It is not a query cookbook and it is not tied to any one schema — the schema lives in `database-mapping`, and this skill governs how you are allowed to touch what that map describes. The whole thing rests on one asymmetry. A read that goes wrong wastes your time. A write that goes wrong spends someone else's money, deletes someone else's history, or quietly corrupts a row that nobody notices for three months. Every rule below exists because that asymmetry is real and because the pressure to skip the rule is strongest exactly when the operation is most dangerous — late, urgent, and "obvious". ## Before Step 0 — read the cases The first action of any operation is reading the cases that already exist, and it comes before the host check because it can change what you are about to do. Past cases are the only place the operator's corrections are written down. When a mutation was wrong and they said so, that sentence is in a case file and nowhere else — not in the schema, not in the code, not in this skill. A directory of them is a record of every way this database has already been got wrong. Find the ones that share a user, an operation type, or a symptom with the task in front of you. Where nothing matches, read the most recent handful anyway: the corrections are recent, and the patterns transfer further than the subject matter does. | Signal in a past case | What it means for you | |---|---| | A field was changed, then reverted | Do not touch that field in a similar operation | | The operator corrected the approach | The corrected one is the approach; the original is a trap that already caught someone | | Rollback SQL was run | Find out why before you repeat the thing that needed rolling back | | A "Note:" aside | A rule discovered mid-operation, which is where the real ones come from | | Several rounds of changes | The final state is the pattern; the earlier ones are the mistakes | ### Rationalizations | Thought | Reality | |---|---| | "I already know how to do this" | The cases hold corrections you have not seen. Knowing the mechanism is not knowing what went wrong last time. | | "This is a different operation" | Corrections transfer across operation types far better than the mechanics do. | | "I will read them if I get stuck" | Stuck is too late: by then the write has happened. Read first. | | "There are too many to read" | Filter by relevance, then read the recent ones. Some is not none. | | "The schema map already tells me this" | The map says what the columns are. A case says which one someone regretted touching. | **Their values are stale; their lessons are not.** A case records an id, a balance and an ancestors array as they were on the day. Take the approach and the correction from it, and re-read every concrete value live — see the next section. ## Every fact comes from a live query Every id, balance, status, referrer, count and relationship you state or act on comes from a `SELECT` you ran in this session against this database. Not from memory, not from training data, not from a case file's recorded values, not from inference. This is not pedantry about sourcing. A failed read that goes unnoticed is indistinguishable from an empty result, and an agent that narrates what it expected to see rather than what it saw will build a dry run on values that never existed. The rule exists because that happened: failed file reads produced confabulated ids and an entire account hierarchy that was not there. - **Re-query rather than recall.** Ids and balances move. Anything you are about to act on gets re-fetched, however recently you saw it. - **A read that errored is not data.** If a query fails or returns nothing, stop and say so. Never continue as though it returned what you wanted. - **Never invent one.** No id, username, count or history that did not come back from a statement. If you do not have it, query it or say you cannot. - **Build the dry run from fresh state**, not from values carried down the session. | Thought | Reality | |---|---| | "I saw that id earlier" | Earlier could be stale, or from the read that failed. Query it again. | | "The case file says it is X" | The case file is history. Confirm it against the database now. | | "That read probably worked" | If you did not see rows, you have no rows. Re-run it. | | "I remember this account's shape" | Memory is not the database. | ## Full identifiers, full dates, one subject per query Three habits that keep a read reviewable. They bind every statement and every sentence you write about one — the dry-run gate covers writes, and these are the failures that happen on the way to it, in material nobody thought needed a gate because nothing was being changed. - **Show the identifier in full.** Never truncate one to `a1b2…` in a dry run, a table, a summary or a chat message. A truncated id cannot be pasted back into a query, cannot be matched against a case file, and conceals the one thing it looks like it is guarding against: two rows that differ only past the cut. - **Show the date on every record.** The case file carries a single date and that date belongs to the operation, not to the row. A record presented bare reads as current, and "that was already fixed last month" is an argument you can only settle if the date travelled with the row. - **One subject per query.** Never pull several subjects with `WHERE id IN (…)` and then attribute the rows by eye. Rows come back unordered and unlabelled, one subject can return three and its neighbour none, and the resulting mix-up presents as a finding rather than as an error. Query once per subject, or group and label explicitly so the attribution is in the output instead of in your head. [The wrong subject](#when-a-number-on-a-screen-disagrees-with-the-database) is the same failure seen from the far end; this is the query shape that prevents it. ## Timestamps come back as text or they come back wrong **Read timestamp columns as text and parse them as UTC.** Where a schema stores `timestamp without time zone` holding UTC — which is the case this discipline governs — the value on the wire is digits with no offset attached, and any client that hands you a native date object has already supplied one. It supplies the *client machine's*. The digits are right; the instant they now denote is wrong, by your own distance from UTC, and the same row therefore reads differently on two laptops. `SET TIME ZONE` does not help, because the conversion happens after the value has left the server. - Select `col::text`, and parse it as UTC. - `NOW()` becomes `(NOW() AT TIME ZONE 'utc')::text`. - A value that only ever crosses a text client such as `psql` is text end to end and is not at risk. **Printing one wrong is the small failure.** The large one is feeding it back in as a window bound: the shifted bound moves the window by that offset, and every record within the offset of a boundary lands in the neighbouring interval — a whole day's batch attributed to the day before or after, from a query that returns rows, raises nothing and looks entirely well. This has been paid for three times. ## Step 0 — the mandatory first step Do this before the first statement of any session against a non-local database. It costs about thirty seconds and it is not skippable for small operations, because "small" is not a property you can establish before you know where you are. Answer three questions, out loud, in the case file: 1. **Which host am I on?** Not which host I intended, not which host the last command used — which host this connection actually resolves to. Verify it (see [Host verification](#host-verification)), then write the answer down. 2. **Where did the credential come from, and how does it travel?** It must come from the secret store. Not a `.env` file you found, not a connection string pasted in the scrollback, not one you remember from last week. A credential lying around in a file is a credential nobody rotated and nobody scoped, and the string in your scrollback may well point at a host that is no longer the one you want. Then it travels as a reference, never as a literal. **The credential's characters appear in no command, no script, and no case record** — what appears is the expression that fetches it: ```sh psql "$()" -c "select ..." ``` Where the store answers freely, that is the whole rule: fetch it inside every command and nothing persists anywhere. Where the store cannot be asked twice — a rate limit, audited issuance, an MFA prompt — issue it once into a file **outside the repository**, `chmod 600`, then read from that file the same way. Delete it as part of the close and record the deletion, exactly as an export. An exported shell variable is not a carrier here: it dies with the command that set it. This is what lets a script be kept. A script in `scripts/` is committed with the case, so the habit is what makes it safe to commit — not a scrubbing pass afterwards. 3. **Does this operation read or write?** State it before you start. This is the fork in the road: reads proceed freely, writes enter the gate. Deciding which one you are doing *after* you have written the statement is how an "investigation" turns into an UPDATE without anyone approving anything. If you cannot answer all three, you are not ready to run anything. ## Reads are free, writes are gated SELECT is unlimited. Investigate as much as you like, from as many angles as you like — over-reading is never the failure mode. Local databases (`localhost`, `127.0.0.1`) are exempt from the gate entirely; do what you want there. Everything that changes state — INSERT, UPDATE, DELETE, DDL, and any transaction block containing them — passes this gate: 1. **Investigate.** Read the current state of exactly the rows you intend to touch. 2. **Produce the dry run.** The exact statement you will execute; the full identifiers of every affected row; each affected value as it is now; each value as it will be afterwards; the count of rows the statement will match; and the statement that undoes it. 3. **Present it and wait.** A human approves the dry run. Not the plan, not the goal — the dry run. 4. **Execute**, unchanged. If the statement you run differs by so much as a predicate from the one that was approved, it was not approved. 5. **Verify.** Re-read the affected rows and show what they now contain. ### The dry run is the artifact being approved This is the part that gets misunderstood, so it is worth being blunt about. When someone says "yes, fix it", they have approved an *outcome*. They have not seen the predicate. The predicate is where the damage lives: a `WHERE` clause missing one condition turns a three-row fix into a table-wide overwrite, and the outcome the approver had in mind is identical in both cases. Only the dry run distinguishes them, because only the dry run states the row count and lists the identifiers. So the object under review is the text of the statement and the list of rows it matches. Approval of anything else — the ticket, the diagnosis, your summary of what you are about to do — is not approval of the write. An invented example of a dry run at the right level of detail: ``` Statement: UPDATE account SET status = 'active' WHERE id IN ('a1b2', 'c3d4'); Matches: 2 rows (verified by running the same predicate as a SELECT first) id status (now) status (after) a1b2 suspended active c3d4 suspended active Undo: UPDATE account SET status = 'suspended' WHERE id IN ('a1b2', 'c3d4'); ``` The identifiers and values there are invented for illustration. Note the row count is *measured*, by running the identical predicate as a SELECT, rather than asserted from what you believe the data looks like. ### Rationalizations | Excuse | Reality | |---|---| | "This is a one-line fix" | Row count, not statement length, decides the blast radius. One line can match every row in the table. | | "They already asked me to fix it" | Asking for an outcome is not approving a predicate. Those are different objects. | | "It is only seeded or throwaway data" | It is sitting in the production database, next to everything else, matched by the same `WHERE` clause. | | "It is urgent" | Then produce the dry run quickly. Urgency compresses the writing, not the approval. | | "I will verify afterwards" | Verification tells you what you broke. It does not unbreak it. | | "It is just a rollback" | A rollback is a write. It gets its own dry run and its own approval. | | "I know this schema" | Knowing the schema is not authorization, and confidence is precisely the state in which people forget a predicate. | ## The case Every operation leaves a dated folder. Reads included. **Why.** An operation with no record cannot be checked afterwards by anyone, including you. When a number looks wrong next week, the only question that matters is "what exactly was run, and what came back?", and the only acceptable answer is a transcript. "I ran something like this" is not a record — it is a reconstruction from memory, produced by the person with the strongest incentive to remember it as correct. The case file also removes an entire class of argument: with the exact query and the exact output on disk, disagreements are about interpretation instead of about what happened. **The record is literal.** Paste the statement as executed and the output as returned. Not a paraphrase, not a rounded summary, not "returned about forty rows". If the output is enormous, record the shape faithfully — the first rows verbatim, the exact total count, and how you obtained the count — and say that you truncated. A summary is a claim; the transcript is evidence. This has no redaction exception, and needs none: a statement written per Step 0 carries the fetch expression rather than the credential, so what you ran and what you record are the same publishable line. A statement that would need redacting is one to re-run correctly, not to edit on the way into the file. **Write it as you go.** Open the file before the first statement, with the context and what you expect to find. Update it after each phase. A case file written entirely at the end records your conclusion, not your path, and the path is where the mistakes are visible. **It keeps the language it was worked in.** A case file is an artifact of the work rather than prose about it: it records what was asked, the exact statement, and what came back, on a date, by whoever was there. Translating one afterwards produces a paraphrase of evidence made by someone who was not present, and nobody can check a directory of that. So a case worked in Portuguese stays Portuguese, and this is not untidiness to be cleaned up later. Prose *about* the area — a reference someone maintains, a heading — is written in the project's documentation language. **Shape:** ```markdown # Date: YYYY-MM-DD Database: Type: read | write | both ## Prior cases read ## What was asked ## What was run ## What came back ## What changed ## Verification ## Outcome ## Credential scan ``` Drop the write-only sections when nothing was written. Keep the rest even for a two-minute lookup; a two-minute lookup that later turns out to have answered the wrong question is exactly the case you will want to reread. **`Prior cases read` is what closes the loop.** The reading described above is real work and it is thrown away unless the file names the cases consulted and what each one changed. "Nothing matched; read the most recent five" is a result too: it tells the next agent the gap is genuine rather than unsearched, and spares them re-deriving the same reading from the whole directory. Cases belong in the project, not in this skill — they contain that project's data. Put them in a project-local directory, one **folder** per operation; `.maccing/database/ops/` is the default if the project has no established place. ``` .maccing/database/ops/ YYYY-MM-DD-/ case.md <- always this name, so the entry point is at a known path .txt <- evidence the case cites scripts/