What's in this doc
- 1. The discovery (zero collections, 73 downloads)
- 2. Account state map
- 3. OAuth and scope ladder
- 4. Endpoints in active use
- 5. The 73-download goldmine
- ★ The 73-download taste heatmap
- 6. Generated images vs reference corpus
- ★ The operational to aspirational taste pivot
- 7. The visual-gate architecture
- ★ May 27 fix-to-API connection map
- ★ The Shutterstock fuckup chronicle
- ★ The visual gate loop
- 8. Implementation runbook + cost + open items
The discovery that flipped the architecture
On 2026-05-15, Dr. DNicole escalated 11 days of visual-direction frustration: the brain kept generating cream-and-gold off-brand cards, the corrections were reactive, and the DEMO_ROTATION on content_drop_v3 kept firing the same three avatars in positions 0, 1, 2. Two weeks of memos treated this as a prompt-engineering problem. It was a corpus problem. The fix was not better words. The fix was finding what her own eye had already said yes to.
We assumed her Shutterstock collections would be the reference corpus. Collections are user-curated, saved-for-later, the strongest taste signal a stock platform exposes. The plan: pull every image in every collection, embed them, and use the centroid as a brand-fingerprint.
Result The v2/images/collections endpoint returned 0 collections. She has never saved a single image to a Shutterstock collection. The whole corpus assumption was wrong.
Probing further up the user-scope endpoints, v2/images/licenses answered with 73 downloads, every one of them on her account drdnicole96. These are not photos she saved for later. These are photos she paid to download and use. A purchase signal is louder than a save signal by an order of magnitude.
Result One image, ID 1571681605, appears in the license log 4 separate times. She did not just buy it once; she came back to it. That is a brand-defining datapoint.
The signal ladder, ranked by trust
Searched < Liked < Saved < Paid < Re-paid. A search is curiosity. A like is a passing reaction. A save is intent. A paid download is a commitment. A repeated paid download on the same asset is a declaration. Our existing brand-rules JSON encodes what she said. The licenses endpoint reveals what she did. The two will overlap, but where they differ, the action wins.
What this means for content_drop_v3 right now
As of 2026-05-27, the pipeline flips: Shutterstock is the PRIMARY image source. The May 27 test-fire of the original .NEW_PILLAR_REWIRE (OpenAI-primary) reproduced the May 15 failure mode (system claims Shutterstock and ships AI). Robert pulled back the 10-second peek that same morning (Gmail msg 19e696af6e250e08), halted the cron, and shipped the Shutterstock-primary sibling (.SHUTTERSTOCK_PRIMARY). Today the system uses real photographer-shot Shutterstock photos licensed via Dr. DNicole's drdnicole96 subscription as the default source; OpenAI gpt-image-1 only fires if all 10 Shutterstock candidates per pillar fail the visual gate.
Who owns what, with what allotments
Inventory of the Shutterstock account, subscriptions, and download history attached to IEXDG, as verified against live API calls on 2026-05-26.
Username: drdnicole96
Display name: Denean Fields
Customer ID: 483093847
App registered: IEXDG Nexus Content
One account, one identity. All 73 licensed downloads sit under this user. The bearer token in iexdg-mcp.env is scoped to this user, which is what unlocked the licenses endpoint at all.
Licensed downloads (lifetime): 73
Most recent download: 2026-05-14T12:07:07Z
Most downloaded asset: image 1571681605 (4 separate licenses)
Collections: 0
Licenses still available this month: 100,500+ across all 5 subs
Active subscriptions on 2026-05-26
| Subscription | License type | Asset class | Allotment this month | Expires |
|---|---|---|---|---|
| 38c68e828f0e430b bf7dea8d8c806fc1 |
integrated_media | images | 500 of 500 standard images | 2027-04-15 |
| fb31e1246570 448194e298db410fce1a |
standard | images | 100,000 of 100,000 downloads | 2026-06-15 |
| fb31e1246570 448194e298db410fce1a |
aigen_actions | images (AI-gen) | 100 of 100 generations | 2026-06-15 |
| fb31e1246570 448194e298db410fce1a |
footage_standard | videos | 100,000 of 100,000 downloads | 2026-06-15 |
| fb31e1246570 448194e298db410fce1a |
audio_standard | audio | 100,000 of 100,000 downloads | 2026-06-15 |
Cost line · we have headroom
The 500-image Free plan on subscription 38c68e82... has not been touched this billing cycle. The 100,000-image Standard plan on fb31e124... is also untouched. Nothing in the proposed visual-gate workflow consumes a licensed download because previews are free. Cost impact of the corpus build: zero on Shutterstock's side.
The user-scope tier is the one that matters
Most public Shutterstock API examples on the internet show a two-line cURL against v2/images/search with an OAuth client_credentials grant. That tier sees catalog metadata and nothing else. The tier we hold is one level up: a long-lived user-scope bearer token. It is the difference between renting a search index and walking into the room with her wallet.
Credentials on disk
| Credential | Where it lives | Scope | Use |
|---|---|---|---|
IEXDG_SHUTTERSTOCK_API_KEY |
source/secrets/env/iexdg-mcp.env :34 | User scope (v2 bearer, drdnicole96) | The prized token. Read access to licenses, subscriptions, user, collections, plus all search and image endpoints. |
IEXDG_SHUTTERSTOCK_CLIENT_ID |
source/secrets/env/iexdg-mcp.env :35 | App-only after exchange | OAuth client_credentials companion. Useful as a fallback if the user token ever expires. |
IEXDG_SHUTTERSTOCK_CLIENT_SECRET |
source/secrets/env/iexdg-mcp.env :36 | Partner with the client_id | Server-side only. Never ship to a browser or a public endpoint. |
IEXDG_SHUTTERSTOCK_SUBSCRIPTION_ID |
source/secrets/env/iexdg-mcp.env :37 | Subscription pointer | The 500-image Free plan ID. Used when a license call needs a target subscription. |
Token shape (what the v2 bearer looks like)
# The user-scope token is a self-describing base64 string starting with v2/ v2/Y24wbjdqOVlTWGZLRk5ma052czg3UVVzbExTR1F0SnkvNDgzMDkzODQ3L2N1c3RvbWVy... ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^ client_id (cn0n7j9YSXfKFNfkNvs87QUslLSGQtJy) customer_id (483093847) # Validity: long-lived. Expires only on Dr. DNicole's account password change. # Scope claims (per the master_logins.txt registration record): # licenses.create collections.edit user.email user.view subscriptions.view
Fallback OAuth client_credentials exchange
curl 'https://api.shutterstock.com/v2/oauth/access_token' \ -X POST \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'client_id=$IEXDG_SHUTTERSTOCK_CLIENT_ID' \ -d 'client_secret=$IEXDG_SHUTTERSTOCK_CLIENT_SECRET' \ -d 'grant_type=client_credentials' \ -d 'scope=' # Returns: { "access_token": "v2/...", "expires_in": 2592000, "scope": "" } # Note: empty scope = search-only tier. Cannot read licenses. # Use this ONLY when the user-scope bearer fails. See iexdg_apis.py:213.
Why the tier we have is rare
Shutterstock issued the long-lived user-scope bearer at app-registration time, scoped to drdnicole96. There is no public refresh flow for this token; Shutterstock's documented OAuth path produces only the client-credentials variant. Treat the bearer in iexdg-mcp.env as a small, irreplaceable asset. If she changes her account password, the token dies and the licenses endpoint goes dark with it. Mitigation: archive a redacted copy of the response payloads now (already done in source/secrets/shutterstock_licenses_page1.json and shutterstock_subs.json) so the corpus survives any auth event.
Every Shutterstock surface the IEXDG stack calls
A single source of truth for which endpoints the pipeline already calls, which ones today's probe added to the inventory, and what each one returns. All paths assume base https://api.shutterstock.com.
| Endpoint | Method | Tier required | Where it is called | Returns |
|---|---|---|---|---|
| /v2/oauth/access_token | POST | client_credentials | shutterstock_saved_photos_puller.py :143 | Fallback bearer token (search-only scope). |
| /v2/images/search | GET | search | iexdg_apis.py :213 (legacy fallback path in content_drop_v3) | Catalog search results. Used by the seeded fallback mode of the puller. |
| /v2/user | GET | user.view | shutterstock_saved_photos_puller.py :130 (probe) | Account identity: username, customer_id, display_name. Confirms tier. |
| /v2/user/subscriptions | GET | subscriptions.view | Probed 2026-05-26, dumped to shutterstock_subs.json | 5 active subscriptions with allotments, expirations, asset classes. |
| /v2/images/licenses | GET | licenses.create (read implied) | Probed 2026-05-26, dumped to shutterstock_licenses_page1.json | The 73-download history. Goldmine. |
| /v2/images?id=...&view=full | GET | search | shutterstock_saved_photos_puller.py :233 (batch meta) | Full image metadata for up to 100 IDs per call: description, keywords, contributor, preview URLs, model release status. |
| /v2/images/{id} | GET | search | For per-image deep enrichment | Same as batch endpoint but for one ID. Used when the batch call returned partial data. |
| /v2/images/collections | GET | collections.view | shutterstock_saved_photos_puller.py :188 | 0 collections for drdnicole96. The empty answer that drove the pivot. |
| /v2/images/collections/{id}/items | GET | collections.view | shutterstock_saved_photos_puller.py :210 | Per-collection item lists. Dormant until collections appear. |
Two cURL recipes worth keeping at hand
curl 'https://api.shutterstock.com/v2/user' \ -H "Authorization: Bearer $TOKEN" # Returns: # { "id": "483093847", # "username": "drdnicole96", # "display_name": "Denean Fields" }
curl 'https://api.shutterstock.com/v2/images/licenses?per_page=100&page=1' \ -H "Authorization: Bearer $TOKEN" # Returns: # { "total_count": 73, # "data": [ { id, image.id, download_time, license, ... }, ... ] }
The 73-download corpus, anatomized
A single API call against v2/images/licenses turned 11 days of guesswork into a concrete training set. Below is the shape of the data, the signal worth extracting, and the one image that punches above its weight.
Repeated-download signal
Shutterstock image ID 1571681605 appears in the license log four separate times, all under subscription_id 38c68e82..., all between 2026-05-13 and 2026-05-14, all on the integrated_media license. That is not an accident. She returned to the same asset four times in 36 hours. Whatever that image looks like is, by her own behavior, the closest single point to the brand-fingerprint we have. The visual gate uses it as the anchor when computing the corpus centroid.
License payload shape
{
"total_count": 73,
"page": 1,
"per_page": 100,
"data": [
{
"id": "e13fe8731cdd6e6315370fa798a8462ff0",
"user": { "username": "drdnicole96" },
"license": "standard",
"download_time": "2026-05-14T12:07:07.000Z",
"is_downloadable": true,
"image": {
"id": "2408698777",
"format": { "size": "huge" }
},
"subscription_id": "MDcwODRhYzVjZjU3..."
},
...
]
}
Signals extractable from a single page
The download_time distribution. A burst of 50 downloads in one afternoon means a deliberate sourcing session. Spread across weeks means routine pulls. The 4-time image was a session; the rest are routine.
Each image.id hydrates via the batch metadata endpoint, which surfaces description, keywords, contributor, model release status, aspect, and free preview URLs. Embed every preview; the centroid of all 73 embeddings is the brand-fingerprint.
Most are integrated_media under the Free 500/month plan. A few are standard. The license choice itself is a signal: she pays standard for the assets she means to use in revenue-generating contexts.
What the corpus is not
73 is a small N. Centroid-based matching is not a deep-learning model. The corpus does not catch nuance like "looks like a 1990s magazine cover" without further labeling. What it does catch reliably: gross off-brand drift (cream-and-gold templates, staged stock generics, the patterns that triggered the 2026-05-15 escalation). Score-then-regenerate eliminates the worst 30 percent of OpenAI generations before they ever reach her. That is the win on day one.
The 73-download taste heatmap
Every license event she has ever logged, rendered as a single grid. Each cell is one unique image. Cell warmth equals how many times she returned to that image and paid again. The brightest cell is image 265888400, downloaded thirteen separate times. Cool cells are one-and-done. The shape of the heat map IS the shape of her operational taste.
Read the paradox before you draw the conclusion
This grid looks like a YES board. It is not. These are the photos she bought in production sessions over months of speaking-deck builds, capability slides, and procurement-ready handouts. They are operational taste, not aspirational taste. The May 2026 escalation is her own behavior versus her own standard: the smiling-team conference-room stock that fills the hot cells of this grid is precisely the aesthetic she now wants the brain to STOP serving. Therefore this heatmap is the brand-fingerprint of the NO board, not the YES board. The corpus tells the visual gate what to refuse, not what to mimic. The next centerpiece shows where the YES board comes from instead.
Shutterstock as fallback, as reference corpus, or both
As of 2026-05-27, the pipeline flips: Shutterstock is the PRIMARY image source. The May 27 test-fire of the original .NEW_PILLAR_REWIRE (OpenAI-primary) reproduced the May 15 failure mode (system claims Shutterstock and ships AI). Robert pulled back the 10-second peek that same morning (Gmail msg 19e696af6e250e08), halted the cron, and shipped the Shutterstock-primary sibling (.SHUTTERSTOCK_PRIMARY). Today the system uses real photographer-shot Shutterstock photos licensed via Dr. DNicole's drdnicole96 subscription as the default source; OpenAI gpt-image-1 only fires if all 10 Shutterstock candidates per pillar fail the visual gate.
- OpenAI gpt-image-1 is the default generator
- Shutterstock listed as a fallback that never fires
- No source-level brand check, AI imitates editorial
- Reproduces the May 15 escalation ("claims Shutterstock and ships AI")
- May 27 test-fire confirmed the failure mode live
- Architecture from May 2 through May 27, now retired
This was the architecture from May 2 through May 27. Dr. DNicole's May 15 escalation named the failure ("claims Shutterstock and ships AI") and the May 27 test-fire reproduced it exactly. Postmortem at /Sprint 37 in the action ledger.
- Shutterstock search is the default per pillar + sub_vignette
- Up to 10 candidates filtered by her doctrine
- Editorial, documentary, multi-ethnic, action-oriented
- NOT staged, smiling, or conference-room
- Each candidate scored by the Claude Vision gate, first >= 12 wins
- OpenAI gpt-image-1 only fires if all 10 candidates fail the gate
Per pillar + sub_vignette, Shutterstock search returns up to 10 candidates filtered by her doctrine (editorial, documentary, multi-ethnic, action-oriented, NOT staged/smiling/conference-room). Each candidate scores through the same Claude Vision visual gate built in the .NEW_PILLAR_REWIRE sibling. First candidate to score >= 12 wins. OpenAI gpt-image-1 ONLY fires if all 10 Shutterstock candidates fail the gate (which on her 73-paid-download account should approach zero).
- Prior recommendation, replaced by Path B
- Treated Shutterstock as a REFERENCE corpus only
- Kept OpenAI as the PRIMARY GENERATION source
- CLIP-similarity scoring on top of the wrong source
- Reproduced the May 15 failure mode in practice
- Retired 2026-05-27, see Path B
This was the prior recommendation. Deprecated because treating Shutterstock as a REFERENCE corpus for CLIP-similarity scoring while keeping OpenAI as PRIMARY GENERATION reproduced the May 15 failure mode. Replaced by Path B.
Comparison across 8 axes
| Axis | A · fallback only | B · corpus only | C · both (rec) |
|---|---|---|---|
| Catches off-brand drift | 🟥 never | 🟩 yes | 🟩 yes |
| Survives OpenAI outage | 🟨 rarely fires | 🟥 no fallback | 🟩 yes |
| Cost (Shutterstock side) | 🟩 $0 | 🟩 $0 | 🟩 $0 |
| Cost (embedding gate) | n/a | ~$0.001 per gen | ~$0.001 per gen |
| Implementation effort | none, already wired | 3-4 hours | 3-4 hours (B reuses A) |
| Failure loudness | 🟥 silent off-brand | 🟩 logs every gate fail | 🟩 logs every gate fail |
| Reuses 73-download corpus | 🟥 ignored | 🟩 central | 🟩 central |
| Reversibility | n/a | flag flip | flag flip |
Recommendation · PATH C
Keep the existing shutterstock_search() path as a true generation fallback for OpenAI outages (low frequency, real upside when it happens). Add the visual gate as a hard, fail-closed step after generation, scoring each candidate against the centroid of her 73 paid downloads. Either failure mode (off-brand image or down provider) is handled. Both gates compose with the existing Tiffany Gate on copy. Confidence: high. Implementation: half a day. Cost to operate: under one dollar per month.
From operational taste to aspirational taste
The 73-download corpus is what she did when the deadline was tomorrow. Her May 2026 rubric is what she wants when she gets to choose. These are two different brands. The architecture has to honor both: respect the corpus as a NO board, build the YES board from her stated rubric, and pivot the matching method to the only model that can read the qualitative difference.
- Stock corporate, polished and posed
- Smiling teams arranged around tables
- Conference rooms with branded backdrops
- Handshake closeups, generic boardroom
- Cream and gold templates, symmetric layout
- Pillar-agnostic, deadline-driven sourcing
pivot
- Asymmetric composition, intentional negative space
- Multi-cultural casting, real not posed
- Action-oriented, mid-decision moments
- Behavioral storytelling, not symbolism
- Cinematic light, magazine-grade framing
- Per-pillar visual language, no template repeat
The architectural pivot that follows
A CLIP centroid averages the 73 downloads into one vector and asks does this candidate look statistically like that average. The honest answer is, candidates that look more like the average are MORE off-brand by her new rubric, not less. The math is right and the answer is backwards. Claude Vision reads the candidate qualitatively against her stated brief (asymmetric, executive editorial, behavioral, multi-cultural, action-oriented, six-pillar aligned) and returns YES or NO per pillar. The corpus stays in play as a NO board: any candidate that scores HIGH on similarity to the operational centroid is automatically penalized. Two scores, opposite signs, one decision.
The post-generation gate architecture
Today the pipeline is generator, copy gate, ship. The proposed architecture inserts an image gate between generation and copy gate, and makes both gates hard, blocking, fail-closed. A regenerate-on-fail loop with a maximum of three attempts keeps the pipeline self-healing.
Why centroid first, k-NN later
Compute the mean of all 73 embedding vectors. One vector. Score = cosine similarity between candidate and centroid. Pros: trivial to implement, fast at inference, well-behaved with N=73. Cons: averages away nuance; an image that is on-brand for Pillar 2 but not Pillar 5 still gets one global score.
Score = average cosine similarity to the k nearest corpus members (k=5 is a good default). Catches per-pillar nuance, handles bimodal taste (editorial portrait vs. boardroom scene). Cost: same embedding budget, fractional CPU. Adopt when the corpus crosses ~150 images or when she shares a real-photo shoot per Memory Inventory item #5.
Reference Python (the gate function)
def visual_gate_score(candidate_img_path: Path, centroid: np.ndarray) -> float: """Return cosine similarity in [-1, 1]. Threshold gate at >= 0.72.""" emb = embed_via_claude_vision(candidate_img_path) # ~$0.001 per call return float(np.dot(emb, centroid) / (np.linalg.norm(emb) * np.linalg.norm(centroid))) def generate_with_gate(prompt: str, centroid: np.ndarray, max_tries: int = 3) -> Path | None: for attempt in range(max_tries): img = openai_gpt_image_1(prompt) # or shutterstock fallback score = visual_gate_score(img, centroid) if score >= 0.72: return img prompt = strengthen_brand_constraints(prompt, attempt) # Fail closed. Never ship an off-brand image. return None
Fail-closed is non-negotiable
If 3 generations all score below threshold, the pipeline does NOT ship a card. It posts a heartbeat to cc_post, emails Dr. DNicole from dovewebconsulting@gmail.com with the 3 candidates and the scores, and waits. The whole point of building this gate is to stop the 2026-05-15 pattern. A gate that lets the bad through under pressure is theater. Hard blocking, every time.
Sprint 37 postmortem · 2026-05-27 · OpenAI-vs-Shutterstock fail caught + flipped same day
- ■What broke: R5 pillar rewire preserved the May-2 OpenAI-primary flag. Test-fire on May 27 generated 3 AI cards through Claude Vision. 2 passed the gate but were still AI imitations of the editorial photography Dr. DNicole has paid for 73 times.
- ■Why it broke: nobody asked the deeper question. The R5 rewire bolted the new gate ON TOP of the wrong source. Should have asked: now that we have her real Shutterstock subscription + her doctrine + the gate that can score real editorial vs AI, should the SOURCE itself be Shutterstock? Answer: yes. Always.
- ■Fix: Path B becomes the default. Sibling file
source/scripts/content/content_drop_v3.py.SHUTTERSTOCK_PRIMARYships the rewire. Cron stays HALTED via _GREENLIT_TO_RESUME.txt until Dr. DNicole greenlights a real-photo 10-second peek.
How the May 27 fixes connect to the Shutterstock API
Five named fixes shipped on 2026-05-27 to convert the Shutterstock-primary doctrine from claim to running pipeline. Each fix touches a specific Shutterstock surface (or sits adjacent to one). This map shows the exact endpoint or local boundary each fix modifies, what the fix does, and the data that flows between them. Topic-native to this doc: every arrow goes through a Shutterstock API behavior or a local gate that sits between two Shutterstock calls. Not borrowed from Flywheel, ELCC, or Pipeline diagrams.
query param on /v2/images/search. Every query is seeded from Dr. DNicole's search vocabulary corpus (her per-pillar verbatim phrases plus the go-into-the-pain-point method): iexdg_search_vocabulary_corpus.html. Search FROM the corpus, never generic stock terms like "diverse business team meeting"; the gate only scores the result, it does not choose the search./v2/images/licenses, then downloads the full-res asset via the returned huge_jpg URL and renames to post{i}_LICENSED_{cid}.jpg. WATERMARK_GUARD_RE asserts no _ss_cand_ or 450w remains in the path. A licensed file must never carry the candidate naming pattern.1798175857 case).visual_gate(scratch, ...) instead of visual_gate(str(scratch), ...). The downstream embedder expects a Path object end to end; the str cast was raising on the candidate side and silently degrading the score on the licensed side.Reading the map
Three fixes touch the wire: B4 rewrites the query going into search, P0 turns a passing candidate into a licensed full-res asset via the license endpoint plus the huge_jpg URL, and F4 reorders the doctrine registry so search runs first. Two fixes are local boundaries: I6 sits between candidate download and the accept/reject decision (no Shutterstock call, just two Claude Vision calls plus a tiebreaker), and F6 corrects a type bug at the call-site of the gate. Together they convert the Shutterstock-primary intent into a pipeline that licenses real photographer-shot assets per pillar and refuses to ship until the gate agrees.
The Shutterstock fuckup chronicle
Honest log of every Shutterstock-domain mistake from 2026-05-15 (Dr. DNicole's escalation) through 2026-05-27 (Shutterstock-primary stand-up plus the May 27 reply round). Ten incidents, named clearly, fixes shown per incident. Cataloged here so the next session, the next agent, and the next reader can read one timeline instead of scattered breadcrumbs.
Every entry below is real, dated, and either FIXED with a verifiable patch, ACKNOWLEDGED with a documented response, or OPEN with the next step named. The pattern across all ten, the lesson at the bottom of the page, is that real photography on a client API beats AI imitation every time, the doc + the email + the live system must agree, and a deliverable to the client must be a deliverable (image plus copy) sourced from her own queued plan, never a synthetic generation, and never with internal taxonomy bleeding through.
Dr. DNicole sent her visual-execution escalation email (Gmail msg 19e2bf8f56bdc11b) naming the failure plainly: the system claims Shutterstock and ships AI. The same email asked for proactive validation, not reactive correction. The ChatGPT analysis she attached articulated the systemic reactive-vs-proactive gap that two weeks of memos had treated as a prompt-engineering problem.
Acknowledged same day. The corpus-not-prompt pivot landed in the goldmine discovery on 2026-05-26. The visual gate that scores real editorial photography vs AI imitation was built into the pillar rewire and is now the default on the Shutterstock-primary sibling file.
This Shutterstock API Reference shipped its first cut with Path A "OpenAI primary, Shutterstock fallback" labeled CURRENT and Path C "Both, fallback + reference corpus" labeled RECOMMENDED. The doc itself published the wrong architecture. The actually-correct default (Shutterstock-primary, OpenAI-fallback-only) was not one of the three paths offered.
F4 path-flip on 2026-05-27. Path B "Shutterstock primary, OpenAI fallback" promoted to RECOMMENDED with the recommendation badge; Path A demoted to "deprecated, May 15 pattern." The pivot section above and the runbook now read consistent with the deployed code.
The T1 test-fire of .NEW_PILLAR_REWIRE generated 3 cards via OpenAI gpt-image-1. 2 of 3 scored 12 or above on the Claude Vision gate. Without questioning the SOURCE, those 2 cards were emailed to Dr. DNicole as her 10-second peek (Gmail msg 19e6960440ec1b38). The gate worked, the architecture underneath it did not.
Pulled back the same morning via Gmail msg 19e696af6e250e08. The cron stayed halted via the _GREENLIT_TO_RESUME.txt sentinel. The fix at architecture level became Fuckup 4's response: flip the source itself, not just the gate.
The 10-second peek email had 2 AI-generated cards with zero Shutterstock involvement, reproducing the May-15 failure mode exactly: claims Shutterstock, ships AI. Robert caught it before Dr. DNicole responded. The pattern was: a green gate score is necessary but not sufficient; if the source is wrong, the gate score is irrelevant.
Trust email sent same morning (Gmail msg 19e696e76e447a5a) naming the pattern, owning it, and stating Shutterstock-primary going forward. The cron stayed halted. The sibling rewire .SHUTTERSTOCK_PRIMARY began the same session.
The trust email said "Shutterstock-PRIMARY going forward." The live Shutterstock API reference still said "OpenAI gpt-image-1 as primary." The live Brand Bible loop centerpiece still named OpenAI in its Generate node. Dr. DNicole could open either deployed page and see the apology contradicting published doctrine.
F4 patch-flip on this doc (Path B is now RECOMMENDED, Path A is deprecated). F5 patch on the Brand Bible loop: the Generate node went source-agnostic so it does not name a tool that contradicts the architecture. Email and docs now agree.
First test-fire of content_drop_v3.py.SHUTTERSTOCK_PRIMARY crashed with AttributeError because importlib.util.spec_from_file_location returned None for the .py.NEW_PILLAR_REWIRE extension. Re-fire crashed again on a string-vs-Path type mismatch inside visual_gate. Two bugs back to back.
Both fixed same session. Explicit SourceFileLoader path handles the non-standard extension. The gate call now passes a Path object end to end. Test-fire ran clean after the second patch.
With both build bugs fixed, the Shutterstock-primary test-fire produced: Slot 1 Captaincy / Listening, 10 candidates BLOCKED (the gate is strict, this is the gate working as designed). Slot 2 Culture / Listening, PASSED with a real Shutterstock photo id 1798175857 scoring 22 against a threshold of 12. Slot 3 Competence / Deliberating, 0 candidates returned (search query too narrow). 1 of 3 passed. Below the 2-of-3 unhalt threshold, so the cron stays halted.
Broaden the Shutterstock search queries for Slot 1 and Slot 3 (more synonyms per pillar, less restrictive AND-joins, possibly a second-pass attempt that drops the narrowest term), re-fire the same three slots, target 2 of 3 passes. Then send Dr. DNicole the real-photo 10-second peek. Then unhalt the cron. Tracked as the only open item in this chronicle.
Sent the F1-photograph email to Dr. DNicole with "Pillar: Culture / Sub-vignette: Listening to a Concern" labels printed above the photo. Internal taxonomy traveled onto a public-facing deliverable. The pillar tag is for the gate's bookkeeping, not for her inbox.
Antibody now scrubs Pillar:, Sub-vignette:, gate_score, winner_id, BLOCKED, PASSED, narrative_moment, and post_index from any client email body before send. Pre-flight check fails closed if any of those tokens survive the scrub.
Re-fire the F1 send through the scrubber, confirm zero internal tokens leak, then queue the R-012 retry (image + post copy together).
Shipped the licensed photo with zero post copy. Dr. DNicole replied at 10:02 EDT, verbatim: "you sent the image, but not the content that accompanies it. Without the post copy, there's nothing to check the image against." The image arrived stranded; she had no caption, hook, body, sign-off, or first comment to score it against.
Antibody now asserts hook + body + sign-off + first-comment AND image are present in the same send before the send is permitted. A deliverable that names a photo without naming its post is rejected at the pre-send gate. Both halves ride the same envelope or neither rides.
Resend the F1-photograph deliverable as one unit: image plus hook plus body plus sign-off plus first-comment, sourced per R-013 from her Content Calendar.
Generated a "senior analyst pauses mid-sentence" scene from the post generator instead of pulling Dr. DNicole's actual drafted post from her Notion Content Calendar (db 34601a4a-6f2f-8142-9263-f9989da5cd73). The Content Calendar had 25+ entries queued. The pipeline invented a scene the client never wrote and would never have approved as substitute for her own queued plan.
Antibody now requires a pre-build source-of-truth check on the Content Calendar before any synthetic generation runs. If the calendar holds a queued entry for the pillar + date pair, the pipeline pulls THAT entry. Synthetic generation is only allowed when the calendar returns zero queued entries for the slot, and the synthesis is logged as a substitute pending her review.
Wire the Notion query into the pre-build hook (IEXDG token, not the personal workspace token). Re-fire the R-012 send sourcing copy from the Content Calendar entry, confirm Dr. DNicole's drafted voice carries through to the deliverable.
What this chronicle teaches every future session
Real photography is the default source when the client has API access plus a paid-download history.
Doc + email + live system must match. Ship doc updates whenever the architecture flips, in the same session.
Question the SOURCE on every gate change, not just the gate. A green score on the wrong source is theater.
Scrub internal taxonomy from any client-facing email before send. Pillar tags belong in the gate, not the inbox.
A deliverable ships image + hook + body + sign-off + first comment together, or it does not ship.
Check the client's queued plan (Content Calendar) before any synthetic generation. Her draft outranks an invented scene.
The visual gate loop
The gate is not a one-shot filter. It is a self-reinforcing loop. Every drop she peeks at returns one signal: YES, NO, or silence. Every signal updates the centroids. Every centroid update tightens the next score. By the thirtieth drop the gate is reading her eye more reliably than the original brief did. The diagram below is the production topology, six stations on a closed cycle plus one off-loop quarantine for everything the gate refuses to ship.
Off-loop quarantine · the BLOCKED queue
Every candidate the gate refuses gets logged to cc_post_blocked.jsonl with its score, its pillar tags, its three regeneration attempts, and the centroid version that judged it. Nothing in this queue ships. The queue is the audit trail: month-over-month, the gate's catch rate proves itself or earns a threshold tune. The 2026-05-15 escalation pattern goes here, every time, until the centroids stop confusing it for YES.
Each peek tightens the centroid. Each centroid tightens the next score.
The gate gets smarter every drop.
Ship it in six steps
Sequence to take this doc from architecture to running pipeline. Each step is independently verifiable. Total wall-clock: 3 to 4 working hours plus Dr. DNicole's 10-second peek.
Pull every preview from the 73-download corpus
Extend shutterstock_saved_photos_puller.py with a --from-licenses mode that walks the licenses pages, hydrates each image ID via v2/images?id=...&view=full (batch 100), and writes the preview JPEG plus JSON sidecar into source/assets/dnicole_references/YES_FROM_LICENSES/. Already running in shadow as of 2026-05-26.
python source/scripts/automations/shutterstock_saved_photos_puller.py --from-licenses
Embed each preview
One call per image to claude-sonnet-4-5, the same model the production visual gate uses (or local CLIP if a GPU is handy). Persist the embedding next to the preview as {image_id}.embedding.npy. Total embedding cost for the 73-image corpus: about 7 cents at Claude vision rates.
python source/scripts/automations/embed_reference_corpus.py \ --src source/assets/dnicole_references/YES_FROM_LICENSES \ --provider claude_vision
Compute centroid + threshold
Average the 73 embeddings into a single centroid vector, persist to brand_centroid.npy. Pick threshold by leave-one-out: for each image, compute its similarity to the centroid of the other 72; the threshold is the 25th-percentile score (catches the bottom quartile as off-brand). Empirically this lands around 0.70 to 0.75.
Patch content_drop_v3 to call the gate
Insert visual_gate_score() immediately after the OpenAI generation call in source/scripts/automations/content_drop_v3.py. On score < threshold, regenerate up to 3 times with progressively stronger brand constraints in the prompt. On 3 failures, post to cc_post and skip the card. Keep the existing Tiffany Gate downstream of this.
Manual test-fire of 3 cards
Run content_drop_v3 manually with --dry-run --count=3. Inspect each generated image, each gate score, each gate decision. Look for the failure mode that surfaced 2026-05-15 (cream-and-gold off-brand template). Confirm the gate would have blocked it.
10-second peek with Dr. DNicole, then unhalt the cron
Email her the 3 dry-run cards from dovewebconsulting@gmail.com per the standing sender rule. Wait for the one-word approve. Re-enable the 2 AM cron entry for content_drop_v3 on iexdg-nexus-vm. Watch the first night live. Heartbeat any gate-blocks to the war room.
Cost model · the whole bulletproof for under a dollar a month
| Line item | Unit cost | Volume at full pipeline | Monthly cost |
|---|---|---|---|
| Shutterstock API calls (search, licenses, user, etc.) | $0 | Covered by the 5 active subscriptions | $0.00 |
| Shutterstock previews (free, no license consumed) | $0 | Up to 73 previews stored, refreshed monthly | $0.00 |
| Claude vision embed of new candidate image | ~$0.001 per image | ~600 candidates per month (200 posts × 3 attempts) | ~$0.60 |
| Claude vision embed of corpus (one-time + monthly refresh) | ~$0.001 per image | 73 images, refresh monthly | ~$0.07 |
| OpenAI gpt-image-1 generation (unchanged, baseline cost) | existing | 200 posts, sometimes retried | (already budgeted) |
| Total incremental monthly cost | under $1 | ||
Open items + carry-forward
| Item | Severity | Owner | Action |
|---|---|---|---|
| Tiffany Gate is copy-only today (no image gate) | P0 | DWC | Land the visual gate per the 6-step runbook. Compose with Tiffany Gate, do not replace. |
| DEMO_ROTATION fires fixed positions 0/1/2 | P1 | DWC | Audit positions on every run; force shuffle or pillar-aware rotation. Tracked separately from this doc. |
| 73 downloads is a small corpus by ML norms | P1 | DWC | Start with centroid (Section 7). Move to k-NN at N>=150 or after a real-photo shoot. |
| Real-photo shoot per Memory Inventory item #5 (her own ask) | P2 | Dr. DNicole + DWC | When the shoot lands, those images become the highest-weight slice of the corpus. Visual gate already supports it without code changes. |
| Bearer token dies on Shutterstock password change | P2 | DWC | Archive corpus payloads (already in source/secrets). Add OAuth client_credentials fallback path to the embed pipeline so a token death does not block regeneration of search-derived corpus. |
| Repeated-download anchor (image 1571681605) | P3 | DWC | Weight that image's embedding 4x in the centroid (matches her repeat behavior). Verify it lifts the gate's catch rate, do not let it dominate. |
| 0 collections, treat as evidence not as gap | info | DWC | Her workflow does not use saved-photos. Do not nudge her to start saving. The licenses signal is stronger; pull from there. |
| Turnkey carry-forward: licenses-as-corpus pattern | info | DWC | Candidate for Section 15 of war_room/strategy/turnkey_architecture.html as Pattern 17, born IEXDG 2026-05-26: "paid-download history as brand-fingerprint corpus." |
Verdict · publish-ready
This doc is internal-bench reference, not a client-facing deliverable. It is publish-ready for the IEXDG operations bench: the architecture is defensible, the cost model is honest, the runbook is executable without follow-up, every endpoint claim is verified against real probe output. Before forwarding to Dr. DNicole, strip Section 3's token excerpt and reduce Section 5 to her three key takeaways. For internal use, ship as-is.
Permanent rule locked · 2026-05-27
Permanent rule locked: when the client has live API access to a real photography service AND a paid-download history that proves taste, real photography is the DEFAULT image source. AI generation is the FALLBACK for cases where the catalog returns nothing usable. Default direction can never be assumed inherited from a prior session; CONFIRM against current client state every time the source/gate pair is touched.