Design decisions
Start here
Why DecTog works the way it does, the designs it rejected and why, and the questions still under review.
Why DecTog works the way it does, including the designs it chose not to adopt. Each entry states the decision, the reason and what was rejected. The limits these decisions produce are listed in What DecTog does not do.
Kept as they are
Section titled “Kept as they are”1. Reinstalls go through matching. A reinstall runs the attribution ladder
like a first install and is credited when a signal matches; it carries
is_reinstall: true and is excluded from the Installs KPI and per-link counts.
Only install_state: 'update' and a reused click skip the fingerprint.
Rejected: skipping matching for reinstalls, which would lose re-acquisition
campaigns. Revisit if re-attribution windows become a feature. (2026-10-13)
Note (2026-10-15): the fingerprint is also skipped for an install without an
IP hash, a registration that carries recovered_from, and an app whose
Probabilistic match switch is off.
2. v1 DELETE answers 404 for an unknown slug; the admin DELETE answers 200. Different clients: an SDK or script wants to know the slug was wrong; the dashboard deletes what it lists. (2026-10-13)
3. Fingerprint candidates are every mobile web click, not only clicks that
were sent to a store. Rejected for now: a destination column on clicks
(store, custom, bounce, tail) and a filter on it; the effect is mild and the
migration touches three docs pages. (2026-10-13)
Settled for credibility (2026-10-14)
Section titled “Settled for credibility (2026-10-14)”4. No backward compatibility before the first public release. Nobody integrates DecTog yet, so response shapes, SDK methods and the schema change freely. Rejected: shims, v2 routes and deprecation periods before there is a user to protect. Ends at the first public release.
5. One error envelope. Every route answers
{"error": {"code", "message", "request_id"}}; code is stable, message may
change, and request_id (also x-request-id) finds the log line. The id is a
well-formed inbound x-request-id, else Cloudflare’s cf-ray, else a UUID.
Rejected: keeping a bare {"error": "<message>"} string, which clients had to
parse by text.
6. HEAD is a bot; QA taps are not clicks. A HEAD to a link answers the
social preview page and records nothing; x-dectog-no-track: 1 or ?dtg_nt=1
routes like a real tap without a row. Rejected: counting link checkers and
uptime monitors as visitors.
7. Webhook secrets are write-only. The apps list returns a hint (the last
four characters); a generate/rotate route returns a new whsec_ secret once.
Rejected: returning the secret on every GET and showing it in the form.
Note (2026-10-15): since webhooks moved to their own table, the
webhooks list returns no secret and no hint; creating a webhook and
POST /api/admin/webhooks/:id/rotate-secret return the secret once.
8. Sign-in is limited per IP, never per account. 10 attempts a minute per hashed IP; reset requests 5 per IP and 3 per address. Rejected: a per-account lockout, which lets anyone lock a user out by failing on purpose.
9. setEnabled replaces optOut. One persisted switch, and
DectogConfig(enabled: false) to start off until consent. While off the SDK
makes no request of its own and reads no pasteboard; it holds events on the
device and the launch link’s report in memory, and turning it on sends them and
registers an install that a disabled launch skipped. A revoke drops the held
link reports, not the held events.
Rejected: keeping optOut, which never stopped clicks or the install, next to
a second consent API.
10. Scheme links name their link in dtg_l. Every custom-scheme URI the
server builds ends with dtg_l=<username>/<slug>, and the SDK reads the link
from it. Rejected: reading username and slug from the URI’s shape, which turned
an app route like myapp://products/42 into user products, slug 42.
11. The webhook hides link fields unless the install is credited, exactly
like the SDK verdict. Rejected: sending slug next to matched: false on a
strict-mode install, which invited consumers to credit it.
12. Raw IP addresses are opt-in. Matching, fraud rules and rate limits use
only the salted hash; STORE_RAW_IP=1 also keeps the raw address for the
Customers page. Rejected: storing raw IPs by default for a debugging view.
Note (2026-10-19): reversed by the owner. Raw IPs are now stored by default,
so the Customers page shows the install and click IP on every deployment, and
STORE_RAW_IP=0 turns that off. Matching, fraud rules and rate limits still
use only the salted hash, and the retention sweep still clears the raw address.
13. healthz and readyz are reserved slugs, because the Worker answers
those paths on every host.
14. No percentage from a zero baseline. An Overview card whose previous window was 0 shows no delta. Rejected: reporting +100%.
Settled for deploying and integrating
Section titled “Settled for deploying and integrating”15. Two kinds of key; the account key stays on users (2026-10-14). Apps
ship SDK keys (dtg_pub_…, table api_keys) that cannot change links unless
created with links:write; servers use the account key (dtg_…), which keeps
every scope. Rejected: moving the account key into api_keys, which needs a
rebuild of the users table (its hash column is UNIQUE) on a production
database for no user-visible gain; and one key for everything, which put a
link-deleting credential in every app binary.
Note (2026-10-15): there are now three kinds: site keys (dtg_site_…)
are api_keys rows of kind site that web pages carry for dectog.js, limited
to the two web routes and the origins each key lists.
16. One contract table generates the spec and the route index. Every
/api route is declared once in src/contracts.ts; /openapi.json and the
route list in the server repository’s docs/API_CONTRACT.md are generated from it, and a test fails
when a route and the table disagree. Rejected: zod schemas (a new runtime
dependency for the Worker) and a hand-written spec (it drifts).
Settled for attribution correctness
Section titled “Settled for attribution correctness”17. Repeat taps from one phone are one fingerprint candidate. Clicks from one network whose model or screen profile, OS major, language and CPU family all agree collapse into the newest before ranking, so a double tap no longer ties with itself. The cost: a household with identical phones on one network credits the newer tap instead of nobody. Clicks with neither a model nor a screen profile never collapse; agreeing on OS and language alone is not evidence of one device. Rejected: keeping the tie (the common double tap earned no credit and no routing), and reusing one click id per device at redirect time (a database read on every redirect for a gain the match-time collapse already gives). Note (2026-10-17): CPU family left the signature together with the CPU signal (decision 30). Note (2026-10-23): tied taps on one link now merge too, whatever their signals, when they carry the same click params, UTM tags, referrer, routing rule, browser and place: either one gives the install the same verdict. A Safari double tap carries no model or screen profile, so it never collapsed and ended organic. The cost: two people on one network who tapped the same link and both install may each hold the other’s click row.
18. A same-device reinstall is routed, not credited. When nothing else matches, a reinstall on a device whose earlier install consumed a click gets that click back as routing inside the deterministic window (7 days by default). The credit stays with the first install, so one tap is never counted twice. Rejected: crediting the reinstall (double counting) and leaving it organic (the user who reinstalls right after a tap lands nowhere). Still owed: a device run that confirms the iOS device id survives the reinstall.
Settled for links and analytics
Section titled “Settled for links and analytics”19. Webhook retries run on D1 and the hourly cron. A
failed delivery waits in webhook_deliveries for the hourly job, which sends at
most 300 retries a run, so the 5- and 30-minute retries land at the next run.
Rejected: Cloudflare Queues, which retries within the minute but is a second
service to provision on every deployment. Revisit if a receiver’s backlog
outgrows 300 an hour.
20. Retention counts calendar days in UTC, and day N means exactly day N, as Adjust and AppsFlyer report it; the weekly table counts any activity inside each week. Rejected: 24-hour buckets counted from the install time (an install at 23:00 and an open the next evening share a bucket) and each device’s local day, which needs the device’s time zone joined into every activity row.
21. A deleted link keeps its history under its slug.
Rollups are rebuilt from the raw rows for closed days, never incremented, and a
deleted link’s numbers stay filed under its slug; creating that slug again
answers 409 with "deleted": true until the link is restored or deleted
permanently. Rejected: decrementing the rollups on delete (past totals would
change) and incrementing them on every write (a written row per tap, and drift
whenever a claim, refund or exchange rate changes a row later).
22. The web pixel keeps a first-party cookie and asks for consent where the
law does. dectog.js stores its click id in a 30-day
first-party cookie, waits for dectog.consent(true) in the EU and EEA, the UK
and Switzerland, treats Global Privacy Control as a no, and uses its own key
kind (dtg_site_), one per account. Rejected: sessionStorage, which loses
the click when the visitor comes back in a new tab, and consent in the EU only.
Note (2026-10-15): an account can hold up to 10 active site keys, each with
its own list of origins.
23. An in-app handleUrl open counts as an open, not a click. The
link was already tapped inside the app, so no click row is
written and the Clicks KPI keeps meaning taps on the web.
Settled for trust
Section titled “Settled for trust”24. The audit log is append-only and numbered, without a hash chain. Entries are numbered per account, so a deleted entry shows as a gap in Check for gaps, and kept as long as the account. Rejected: a hash chain with a verify route: whoever can rewrite the database can recompute the chain too, so without an outside anchor it adds a column, not a guarantee.
25. A test environment is a sibling account. Account
acme gets t-acme, with copies of its apps and templates and its own keys,
links, installs, events, webhooks and audit log. Rejected: an is_test flag
on every row, which needed a filter in every query and could leak test data into
live totals.
Settled for the ecosystem and ad networks (2026-10-15)
Section titled “Settled for the ecosystem and ad networks (2026-10-15)”26. Only dec_* parameters survive a deferred install.
A browser tap stores its dec_* and utm_* values and nothing else, so those
are all the install verdict can return (click_params). On a warm open the app
reads the opened URL itself (click.queryParameters, from originalUrl); a
route that must also survive the trip through the store goes in dec_deeplink
(products/42), which works on both paths. Rejected: keeping the tapped
URL’s other parameters on the click to return them after the install: the click
would store whatever a visitor’s URL holds, personal data included, and an app
would build on parameters that only some paths deliver.
27. No ad-network click route. Ad networks cannot send
DecTog their clicks server to server, and the advertising id is stored but never
matched on; an ad’s install is attributed when its tap went through a DecTog
link. Rejected for now: an s2s/click route with per-network tokens and an
advertising-id rung in the ladder. It suits networks that do
not attribute themselves (AppLovin, Unity, ironSource and similar) and needs
the advertising id consented on the device; revisit when a user asks for one
of them.
28. Screen views are their own event type, outside every event total. A
screen_view names the screen in event_name, carries no
revenue, and is left out of re-open counts, rollups, the event counts and lists
of customers and installs, and the events list; it stays in exports, erasure and
retention activity. It has read-outs of its own: per link, the screens reached
within 30 minutes of the link opening the app; per customer and per install, the
app sessions with the screens shown in each, whole sessions, so a customer’s
screens before sign-in show too. Rejected: a custom event named screen_view, the first
sketch, which would have counted every screen in the totals the dashboard
sells; and a table of its own, a second copy of the queue, retention and erasure
paths for one column of difference. 2026-10-18: the type and both read-outs
stay; the storage changed, see 32: one row per session instead of an event
row, and eight index rows, per screen.
29. Each SDK keeps its logic in its own language; native code is shared as
source. flutter_dectog (Dart) and react-native-dectog (TypeScript)
each implement the attribution logic and sit on thin native layers, and the
native reads (store referrers, pasteboard, paste control, Keychain id, install
dates) live once, in framework-free Kotlin and Swift files both bridges call;
the React Native repository copies them and its CI fails on a difference. One
fixture set (the server’s wire responses, the URL cases) and one harness per
SDK keep the implementations equal. The React Native port took about 4,000
lines of TypeScript and a 400-line bridge, with no native code of its own.
Native iOS and Android SDKs, when built, are ports on the same terms: Swift and
Kotlin logic held to the same fixtures, calling the same shared files directly.
Rejected: standalone native SDKs (DecTogKit, dectog-android) that the
Flutter and React Native packages wrap, Grovs’ shape: every fix would wait for
a native release and a wrapper release (R19), the Dart and TypeScript tests
would only test bridges, and two of the four implementations would be thrown
away.
Settled for attribution signals (2026-10-17)
Section titled “Settled for attribution signals (2026-10-17)”30. The fingerprint has no CPU signal. No phone browser tells the click its
CPU: iOS Safari sends no client hints, and Android Chrome answers
Sec-CH-UA-Arch with an empty string (both measured on a simulator and an
emulator). The +5 / −40 check only ever fired on test input, so it is gone,
and the redirect no longer asks for the hint. The install’s cpu_type stays
for the emulator fraud flag. Rejected: reading the architecture from the user
agent, navigator.platform, WebGL or floating-point quirks: every phone is
ARM, so the answer is constant and tells candidates nothing apart.
31. Device settings are recorded on both sides, unscored until
calibrated. Text size, dark mode, Increase Contrast, Reduce Motion and the
UTC offset are read by the page beacon and by the SDK (inside the device tier,
on by default), and the iOS minor version comes from Safari’s Version/ token.
Each is compared on its own and stored as an unscored verdict worth 0
points; on installs that a deterministic signal decided, the verdicts of the
click known to be right go into signal_check and the attribution-quality
panel, and a signal earns points only once those numbers support it. The
reason: two phones of the same model behind one carrier network score the
same today, so when their taps lead to different links nobody is credited. The cost: Apple’s rules forbid
deriving data from a device’s configuration to identify it, and accessibility
settings can suggest a disability, which the EU may treat as health data. An
app that cannot carry that leaves the device tier out or turns the
probabilistic match off, which also stops its link pages reading the
settings. Rejected: hashing settings into one identifier
(R1); canvas, WebGL, audio and installed-font probes (Safari randomises them
or they are the same on every iPhone of one iOS version, and the app has
nothing to compare them with); scoring the settings before any calibration.
Settled for the cost of an app user (2026-10-18)
Section titled “Settled for the cost of an app user (2026-10-18)”32. A session’s screens are one row, written once per flush. The flush
keeps each app session’s screen views on an app_sessions row: the device,
install, customer and link open the session is first known by, the device
time of its first screen, the count, and the first fifty screens as a JSON
list in the order shown. The first flush inserts the row, four written rows
with its indexes (six when the customer and the open are known by then);
every later flush appends to the list and the count, one written row, where
each screen view cost nine as an event. A sign-in in a
session whose screens came before it makes the session the customer’s, so the
screens before sign-in show with the rest; a session keeps the open that
started it, so a link opened while the app already runs is not a second open.
Both read-outs read those rows: the per-link screens after an open, keyed by
the device and the open time, and the sessions of an install or a customer.
A screen view without a session id is accepted and not stored. Rejected:
screen views as events behind narrower indexes, still six written rows per
screen; the screens on the open’s own row (app_opens, app_installs),
which had no place for the sessions a customer or an install lists; an open
per screen inside the list, which no index could serve.
33. An app start is one row per device and day. POST /api/v1/device-ping
writes a device_days row for the device’s first start of the UTC day and
nothing for the rest; the SDKs ping once a day. The retention read-out counts
device days, which is all a ping ever fed. Rejected: one row per app start
(three written rows each, at every start of every device), a timeline nothing
read.
34. An SDK key and its account come from one read, kept per isolate for a
minute. Authentication joins api_keys to users in one statement, and an
isolate keeps the pair for SDK_AUTH_CACHE_SECONDS (default 60), so an SDK
call spends its round trips on its own data: a ping is one statement instead
of three. The isolate that revokes a key or erases a subject forgets the
account at once; the others keep their copy until it expires, so a revoked
key may work, and an erasure may go unseen, for up to a minute. The daily
job deletes the events that slipped past an erasure in that minute.
Rejected: a shared cache (KV, a Durable Object), a round trip of its own;
no cache, which held the measured D1 ceiling at about 130 pings a second.
Rejected designs
Section titled “Rejected designs”Each of these appears in at least one competing project.
| # | Rejected | Why |
|---|---|---|
| R1 | Raw IPs or user agents as matching keys, browser fingerprinting hashes, hardware identifiers | Apple’s rules prohibit fingerprinting and the EU audience cannot ship it; salted hashes and a scored, deterministic-first ladder instead. Note (2026-10-17): the ladder now also compares a few device settings one by one (decision 31); nothing is combined into an identifier |
| R2 | Persistent cookies or third-party tags on link pages | Redirects are cookie-free and stay so |
| R3 | The client choosing windows, install state or verdicts | Windows, state and verdicts are decided server-side |
| R4 | “Bring your own auth” and unauthenticated install or event routes | Every write is authenticated and signed; a future ad-network route gets its own token and rate bucket |
| R5 | An ee/ paywall or plan gating in the engine |
The whole product is AGPL; limits are deployment config, never data model |
| R6 | A multi-service analytics stack (queues, ClickHouse, Tinybird) | Rollup tables in D1 give the read speed with one service |
| R7 | Reporting an install on every launch, or “organic” on a network failure | A sentinel, an install_attempt_id and retry on the next launch |
| R8 | Targeting rules that 404 on non-match | Geo, language, schedule and A/B rules route a visitor; they never gate a link |
| R9 | Opening myapp:// automatically on page load |
iOS shows “Cannot open page” to everyone without the app; schemes open from a tap only |
| R10 | Push messaging, affiliate programs, domain sales, LLM link scanning | Outside the product’s scope; each adds credentials and moving parts |
| R11 | Row-per-impression storage and recomputed attribution views | D1’s size cap and read billing; verdicts are computed at write time |
| R12 | Documenting ahead of code | Docs state what the code does |
| R13 | A CLA on the AGPL server | Inbound = outbound is the simpler story (still an open question) |
| R14 | Silent dedupe of events | Batch answers say what was accepted, duplicated and rejected |
| R15 | Fingerprint first, deterministic signal as the fallback | A deterministic signal never waits behind a guess |
| R16 | Holding the app’s own events until attribution resolves | Events are independent of the verdict; the server stamps matched_slug |
| R17 | Requiring apps to turn off Flutter’s own deep linking | The app_links stream coexists with go_router and Navigator 2.0 |
| R18 | Refusing to run without a URL scheme, or a privacy default baked into build metadata | Warn, never refuse; initialize() and setEnabled are the control points |
| R19 | Wrapping an opaque published native SDK | Shared native code stays source-level and testable in one layer |
Under review
Section titled “Under review”IDFV kept across reinstalls. With persistDeviceId: true (the default) the
iOS SDK stores the vendor-scoped IDFV in the Keychain, so the device id survives
a reinstall. It is vendor-scoped and never leaves the account, but rejected design
R1 lists reinstall-surviving Keychain ids among the patterns to avoid. Decide
whether the default stays on, and say so in the privacy docs either way.