In an earlier post I wrote a short Python function that asks the IETF datatracker six questions about an Internet-Draft and counts the yes answers. I re-ran it three weeks later and two of its answers were wrong. One field does not mean what its name says. Another changes earlier than I said it does. This post fixes both, then runs the fixed version against every draft the DKIM working group has ever had.
Bug one: rfc_number is null on every draft
The old function counted a draft as published when rfc_number was not null. On 13 September it printed this for the DMARC working group's draft:
draft-ietf-dmarc-dmarcbis-41 (83 pages, expires 2025-10-06)
...
no published as an RFC
5/6 signals present
That draft had been published as RFC 9989 on 20 May 2026. The RFC Editor's record lists draft-ietf-dmarc-dmarcbis-41 as its source and RFC 7489 and RFC 9091 as the documents it obsoletes.
The datatracker stores an RFC as its own document record, named rfc9989. That record has rfc_number set. The draft that became it never does. You can see the scale of it with one request:
import json
import urllib.request
API = "https://datatracker.ietf.org/api/v1"
def get(path):
sep = "&" if "?" in path else "?"
with urllib.request.urlopen(f"{API}{path}{sep}format=json") as r:
return json.load(r)
docs = get("/doc/document/?name__startswith=draft-ietf-dkim-&limit=100")["objects"]
print(len(docs), "drafts,", sum(d["rfc_number"] is not None for d in docs), "with rfc_number set")
16 drafts, 0 with rfc_number set
Nine of those sixteen are published RFCs. The link from a draft to its RFC lives in a separate table, under the relationship became_rfc:
rels = get("/doc/relateddocument/?source__name=draft-ietf-dmarc-dmarcbis&relationship__slug=became_rfc")
print(rels["objects"][0]["target"])
/api/v1/doc/document/rfc9989/
There is a cheaper tell on the draft itself. Its states list includes one state of the type draft, and that state's slug is active, expired or rfc. So the fixed check reads the state first and only makes the second request when it says rfc.
The earlier post also said that once rfc_number is set, "the draft is history". On a draft record it is never set, so that sentence described something that does not happen.
Bug two: stream is set at the call for adoption
The earlier post said that when stream stops being null, the document has been adopted. Its example of a draft with nothing going for it yet was draft-brotman-aggregate-performance-reporting. Here are its latest three events:
events = get("/doc/docevent/?doc__name=draft-brotman-aggregate-performance-reporting&limit=3")
for e in events["objects"]:
print(e["time"][:10], e["desc"])
2026-09-24 IETF WG state changed to <b>Call For Adoption By WG Issued</b>
2026-09-24 Changed group to <b>Mail Maintenance (MAILMAINT)</b>
2026-09-24 Changed stream to <b>IETF</b>
On 24 September a call for adoption went out in the mailmaint working group, and the stream, the group and the working group state all changed in the same second. The name still starts with draft-brotman-. Nothing has been adopted. A call for adoption is a question to the group, and the answer can be no.
So a non-null stream means a working group is either considering the document or already owns it. The state of type draft-stream-ietf says which. These are the slugs worth knowing:
c-adopt Call For Adoption By WG Issued
wg-doc WG Document
sub-pub Submitted to IESG for Publication
dead Dead WG Document
The fixed version drops the old "name contains ietf" signal, because the stream state answers the same question with more detail, and prints that state as its own column.
The fixed check
_states = {}
def states(doc):
"""Return {state type: slug}, e.g. {'draft': 'rfc', 'draft-stream-ietf': 'wg-doc'}."""
out = {}
for uri in doc["states"]:
if uri not in _states:
s = get(uri.removeprefix("/api/v1"))
_states[uri] = (s["type"].rstrip("/").split("/")[-1], s["slug"])
kind, slug = _states[uri]
out[kind] = slug
return out
def became_rfc(name):
rels = get(f"/doc/relateddocument/?source__name={name}&relationship__slug=became_rfc")["objects"]
return rels[0]["target"].rstrip("/").split("/")[-1] if rels else None
def row(doc):
st = states(doc)
rfc = became_rfc(doc["name"]) if st.get("draft") == "rfc" else None
signals = [
doc["stream"] is not None,
doc["intended_std_level"] is not None,
doc["ad"] is not None,
doc["shepherd"] is not None,
rfc is not None,
]
return (f"{doc['name']:<38} {doc['rev']:>3} {doc['time'][:10]} "
f"{st.get('draft', '-'):<8} {st.get('draft-stream-ietf', '-'):<8} "
f"{sum(signals)}/5 {rfc or ''}")
def group_report(prefix):
docs = get(f"/doc/document/?name__startswith={prefix}&limit=100")["objects"]
for doc in sorted(docs, key=lambda d: d["time"], reverse=True):
print(row(doc))
group_report("draft-ietf-dkim-")
State lookups are cached because the same dozen state URIs repeat across every document.
Running it on a whole working group
name__startswith returns every draft under a prefix, so one request covers a working group's whole history. Output on 6 October 2026:
draft-ietf-dkim-dkim2-bcp 01 2026-09-09 active wg-doc 1/5
draft-ietf-dkim-dkim2-spec 06 2026-08-28 active wg-doc 1/5
draft-ietf-dkim-dkim2-dns 00 2026-07-20 active wg-doc 1/5
draft-ietf-dkim-dkim2-header 00 2026-05-07 expired dead 1/5
draft-ietf-dkim-dkim2-motivation 02 2026-05-06 expired wg-doc 1/5
draft-ietf-dkim-replay-problem 00 2024-01-29 expired - 0/5
draft-ietf-dkim-mailinglists 12 2020-01-21 rfc wg-doc 5/5 rfc6377
draft-ietf-dkim-rfc4871bis 15 2020-01-21 rfc sub-pub 5/5 rfc6376
draft-ietf-dkim-base 10 2020-01-21 rfc wg-doc 4/5 rfc4871
draft-ietf-dkim-rfc4871-errata 07 2015-10-14 rfc wg-doc 4/5 rfc5672
draft-ietf-dkim-deployment 11 2015-10-14 rfc - 4/5 rfc5863
draft-ietf-dkim-overview 12 2015-10-14 rfc - 4/5 rfc5585
draft-ietf-dkim-ssp-requirements 05 2015-10-14 rfc - 4/5 rfc5016
draft-ietf-dkim-threats 03 2015-10-14 rfc - 4/5 rfc4686
draft-ietf-dkim-ssp 10 2015-10-14 rfc - 4/5 rfc5617
draft-ietf-dkim-implementation-report 06 2011-03-28 expired dead 2/5
The columns are name, revision, the record's time, draft state, working group state, signals out of five (stream, intended status, AD, shepherd, RFC) and the RFC it became.
The prefix spans two eras. The bottom ten rows are the first DKIM working group, which produced RFC 4871 in May 2007 and its replacement, RFC 6376, in September 2011. The top five rows are DKIM2, and replay-problem from January 2024 sits between the two. All of them share one name prefix and one group record.
The time column is not a history. draft-ietf-dkim-base became RFC 4871 in 2007 and its record says 2020-01-21. Six records share 2015-10-14. Those are the dates the database rows were last touched. For when something happened, read the event log or the RFC.
Most of the old RFCs score 4/5 because their shepherd field is empty, while two from the same group have one. Treat a missing field on an old published record as missing data, not as a signal.
Every DKIM2 row scores 1/5: on the IETF stream, with no intended status, no AD and no shepherd yet. Two of the five have expired. The motivation document is still marked as a WG document, while the header document is marked dead.
The group record has its own history
The group's state changes and closing notes come from the same API:
for e in get("/group/groupevent/?group__acronym=dkim&type=changed_state&limit=20")["objects"]:
print(e["time"][:10], e["desc"])
2025-02-20 Charter approved, group active
2024-01-18 State changed to <b>Concluded</b> from Active
2023-02-21 Charter approved, group active
2011-09-26 Concluded group
2006-01-05 Started group
2005-10-01 Proposed group
The closing note from January 2024, read with type=closing_note, says: "WG closing without coming to consensus on even its first deliverable. Little to no activity in several months." The current charter came a year later. Its milestones are in /group/groupmilestone/:
2025-04-30 not resolved | Adopt overview document
2025-04-30 not resolved | Adopt mechanism document
2025-11-30 not resolved | Adopt implementation guide
2025-12-31 not resolved | All documents submitted to the IESG
The first two look met: adopted draft-ietf-dkim- drafts exist that fit both descriptions. Neither is marked resolved. Milestones are the weakest signal on the page. They slip routinely and the records often lag the work, so read them as the schedule the charter set, not as a status.
Following one reference
A third trap shows up when you follow a document's references through the API.
draft-ietf-dmarc-arc-to-historic-00, dated 22 April 2026, asks for RFC 8617 (ARC) to be reclassified as Historic and names DKIM2 as the direction the work has moved in. Its one reference to DKIM2 is informative, to draft-ietf-dkim-dkim2-motivation-02. In the table above, that document has expired.
Ask the API for the ARC draft's references and you get this:
for r in get("/doc/relateddocument/?source__name=draft-ietf-dmarc-arc-to-historic")["objects"]:
print(r["relationship"].split("/")[-2], r["target"].split("/")[-2])
refnorm rfc7208
refnorm rfc6376
refnorm rfc8174
refnorm rfc8617
refnorm rfc2119
refnorm rfc7489
replaces draft-adams-arc-experiment-conclusion
Six normative references and a replaces link. Neither informative reference appears, so the DKIM2 pointer is invisible here. If you are tracing what a document depends on, read the references section of the document itself as well.
What this does not say
An expired motivation document does not mean DKIM2 has stalled. The spec draft reached revision 06 on 28 August and the BCP draft reached revision 01 on 9 September. Drafts lapse after six months unless revised, and a lapsed one stays in the record.
ARC is not Historic. The request is a revision 00 working group document, and its IESG state is "I-D Exists", which means the IESG has not started on it. That draft expires on 24 October 2026 unless a new revision is posted.
And citing the motivation draft was not a mistake. Revision 02 is dated 2 November 2025 and expired in early May 2026, so it was a live draft when the ARC document cited it in April.
The lesson I am keeping
The dmarcbis row was a known answer sitting in the output of the first post, and the function printed it as a no. A check that prints confident yes and no answers needs a case where you already know the answer, run every time the check changes. The fixed version now starts with two:
assert became_rfc("draft-ietf-dmarc-dmarcbis") == "rfc9989"
assert became_rfc("draft-ietf-dkim-base") == "rfc4871"
The API itself is documented at datatracker.ietf.org/api, and none of it needs a key.
Written with AI assistance from my own notes and test results. I checked every fact and command before publishing.
Top comments (0)